From 2246c87f9b1346317027e037ab53da74d0911405 Mon Sep 17 00:00:00 2001 From: Weishi Zeng Date: Sat, 11 Jul 2026 16:50:10 -0700 Subject: [PATCH 1/3] Bump Claude Code pin to 2.1.207 for Claude Fable 5 support Bumps Dockerfile ARG, setup_system.sh default, and the [agent_types.claude].version provisioning pin. Pairs with the mngr PR that bumps CLAUDE_CODE_VERSION in the release Dockerfile and adds claude-fable-5 to the LiteLLM proxy. Co-Authored-By: Claude Fable 5 --- .mngr/settings.toml | 2 +- Dockerfile | 2 +- changelog/mngr-fable.md | 1 + scripts/setup_system.sh | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changelog/mngr-fable.md diff --git a/.mngr/settings.toml b/.mngr/settings.toml index 3c4efa66..22fa4484 100644 --- a/.mngr/settings.toml +++ b/.mngr/settings.toml @@ -74,7 +74,7 @@ settings_overrides__extend = {model = "opus[1m]", fastMode = true} # Pin Claude Code to a specific version; the provisioning-time version check # refuses to start the agent if the installed binary diverges. Bump this in # sync with CLAUDE_CODE_VERSION in the Dockerfile. -version = "2.1.160" +version = "2.1.207" # Share the services agent's CLAUDE_CONFIG_DIR across every other agent so # auth, plugins, marketplaces, and settings are configured exactly once # (inside the services agent) and inherited via the host env file the diff --git a/Dockerfile b/Dockerfile index 51a12462..a10c720e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ ENV PATH="/root/.local/bin:$PATH" # Pin Claude Code; passed to setup_system.sh and recorded for the runtime version # check. Keep in sync with agent_types.claude.version in .mngr/settings.toml and # the default in scripts/setup_system.sh. Bump deliberately, not by accident. -ARG CLAUDE_CODE_VERSION=2.1.160 +ARG CLAUDE_CODE_VERSION=2.1.207 ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} # ============================================================================ diff --git a/changelog/mngr-fable.md b/changelog/mngr-fable.md new file mode 100644 index 00000000..5ca90a29 --- /dev/null +++ b/changelog/mngr-fable.md @@ -0,0 +1 @@ +Bump the pinned Claude Code version from 2.1.160 to 2.1.207 (Dockerfile, setup_system.sh, and the `[agent_types.claude].version` pin in .mngr/settings.toml), enabling the Claude Fable 5 model in workspaces. Lands together with the matching mngr PR that bumps the release Dockerfile pin and adds `claude-fable-5` to the LiteLLM proxy. diff --git a/scripts/setup_system.sh b/scripts/setup_system.sh index 52ac1a20..093c1838 100755 --- a/scripts/setup_system.sh +++ b/scripts/setup_system.sh @@ -18,7 +18,7 @@ provision_skip_if_done setup_system : "${TTYD_VERSION:=1.7.7}" : "${CLOUDFLARED_VERSION:=2026.3.0}" : "${UV_VERSION:=0.11.7}" -: "${CLAUDE_CODE_VERSION:=2.1.160}" +: "${CLAUDE_CODE_VERSION:=2.1.207}" : "${MODAL_VERSION:=1.4.2}" : "${NODE_MAJOR:=20}" : "${LATCHKEY_VERSION:=2.20.0}" From 24390d7573733c08e3d719a6923a3555b513df08 Mon Sep 17 00:00:00 2001 From: Weishi Zeng Date: Tue, 14 Jul 2026 14:51:21 -0700 Subject: [PATCH 2/3] Enable auto_disable_questions and slim the raw disallowed-tools list AskUserQuestion moves from the raw cli_args --disallowed-tools list to mngr's typed auto_disable_questions setting. Requires the vendored mngr to carry that field (ClaudeAgentConfig forbids unknown keys), so this merges only after mngr#2437 lands and vendor/mngr is re-synced. Co-Authored-By: Claude Fable 5 --- .mngr/settings.toml | 3 ++- changelog/mngr-fable.md | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.mngr/settings.toml b/.mngr/settings.toml index 22fa4484..e7592b56 100644 --- a/.mngr/settings.toml +++ b/.mngr/settings.toml @@ -69,7 +69,8 @@ yes = true [agent_types.claude] auto_dismiss_dialogs = true auto_allow_permissions = true -cli_args = """--dangerously-skip-permissions --disallowed-tools AskUserQuestion,ExitPlanMode,TodoWrite,TaskCreate,TaskList,TaskUpdate""" +auto_disable_questions = true +cli_args = """--dangerously-skip-permissions --disallowed-tools ExitPlanMode,TodoWrite,TaskCreate,TaskList,TaskUpdate""" settings_overrides__extend = {model = "opus[1m]", fastMode = true} # Pin Claude Code to a specific version; the provisioning-time version check # refuses to start the agent if the installed binary diverges. Bump this in diff --git a/changelog/mngr-fable.md b/changelog/mngr-fable.md index 5ca90a29..9b813acf 100644 --- a/changelog/mngr-fable.md +++ b/changelog/mngr-fable.md @@ -1 +1,3 @@ Bump the pinned Claude Code version from 2.1.160 to 2.1.207 (Dockerfile, setup_system.sh, and the `[agent_types.claude].version` pin in .mngr/settings.toml), enabling the Claude Fable 5 model in workspaces. Lands together with the matching mngr PR that bumps the release Dockerfile pin and adds `claude-fable-5` to the LiteLLM proxy. + +Switch AskUserQuestion blocking from the raw `cli_args` `--disallowed-tools` list to mngr's new typed `auto_disable_questions = true` setting (requires the vendored mngr to include that field; merge after the vendor/mngr re-sync). From 609b5d375a234c74c6b043264936170e3af80197 Mon Sep 17 00:00:00 2001 From: Weishi Zeng Date: Tue, 14 Jul 2026 15:11:55 -0700 Subject: [PATCH 3/3] Sync vendor/mngr to HEAD (039f016976) Tracks 039f0169768ad2d70484ff32da078bba86448c41 in mngr. --- .../mngr/.github/actions/tmr-setup/action.yml | 2 +- .../mngr/.github/workflows/release-tests.yml | 4 +- vendor/mngr/apps/minds/CHANGELOG.md | 6 + .../mngr/apps/minds/UNABRIDGED_CHANGELOG.md | 48 ++ .../changelog/gabriel-archetypal-mouse.md | 13 + .../hynek-bump-latchkey-to-2.20.2.md | 1 + .../apps/minds/changelog/mngr-fct-rename.md | 3 - .../mngr-inline-fn-ratchet-doublecount.md | 1 + vendor/mngr/apps/minds/electron/backend.js | 7 +- .../apps/minds/electron/chrome-crashed.html | 80 +++ .../minds/electron/content-relay-preload.js | 8 + vendor/mngr/apps/minds/electron/crashed.html | 167 +++++ .../mngr/apps/minds/electron/log-rotation.js | 201 ++++++ vendor/mngr/apps/minds/electron/logger.js | 86 +++ vendor/mngr/apps/minds/electron/main.js | 243 ++++++- vendor/mngr/apps/minds/electron/preload.js | 3 + .../apps/minds/electron/pyproject/uv.lock | 116 +++- vendor/mngr/apps/minds/electron/sentry.js | 72 +- vendor/mngr/apps/minds/imbue/minds/cli/run.py | 2 +- .../minds/desktop_client/agent_creator.py | 39 +- .../desktop_client/agent_creator_test.py | 89 +++ .../imbue/minds/desktop_client/api_models.py | 6 + .../imbue/minds/desktop_client/api_v1.py | 10 + .../imbue/minds/desktop_client/api_v1_test.py | 5 + .../minds/imbue/minds/desktop_client/app.py | 13 +- .../minds/desktop_client/discovery_health.py | 21 +- .../desktop_client/e2e_workspace_runner.py | 7 +- .../e2e_workspace_runner_test.py | 11 + .../imbue/minds/desktop_client/forward_cli.py | 44 +- .../minds/desktop_client/forward_cli_test.py | 34 + .../minds/desktop_client/recovery_probe.py | 63 +- .../desktop_client/recovery_probe_test.py | 47 +- .../imbue/minds/desktop_client/templates.py | 30 +- .../templates/pages/Create.jinja | 32 +- .../minds/desktop_client/templates_test.py | 23 + .../mngr/apps/minds/imbue/minds/primitives.py | 51 ++ .../apps/minds/imbue/minds/primitives_test.py | 24 + .../apps/minds/imbue/minds/test_ratchets.py | 2 +- .../minds/imbue/minds/utils/sentry/core.py | 49 +- .../imbue/minds/utils/sentry/core_test.py | 33 +- vendor/mngr/apps/minds/package.json | 2 +- vendor/mngr/apps/minds/pnpm-lock.yaml | 18 +- .../minds/scripts/electron_full_flow_e2e.py | 8 +- .../apps/minds/test/unit/log-rotation.test.js | 119 ++++ .../mngr/apps/minds/test_snapshot_resume.py | 36 +- vendor/mngr/apps/modal_litellm/README.md | 4 + vendor/mngr/apps/modal_litellm/app.py | 8 + .../modal_litellm/changelog/mngr-fable.md | 1 + .../UNABRIDGED_CHANGELOG.md | 6 + .../changelog/mngr-fct-rename.md | 3 - .../plan-dockview-named-layouts.md | 80 +++ .../plan-electron-log-and-crash-page.md | 107 +++ .../plan-forward-services-cache.md | 111 ++++ .../plan-imbue-cloud-sticky-agent-labels.md | 47 ++ .../plan-mngr-forward-http2.md | 73 ++ vendor/mngr/dev/CHANGELOG.md | 23 + vendor/mngr/dev/UNABRIDGED_CHANGELOG.md | 20 + .../dev/changelog/gabriel-archetypal-mouse.md | 1 + .../changelog/mngr-add-dockview-profiles.md | 3 + vendor/mngr/dev/changelog/mngr-fable.md | 3 + vendor/mngr/dev/changelog/mngr-fct-rename.md | 7 - vendor/mngr/dev/changelog/mngr-mngr-msg.md | 1 + vendor/mngr/libs/imbue_common/CHANGELOG.md | 5 + .../libs/imbue_common/UNABRIDGED_CHANGELOG.md | 6 + .../mngr-inline-fn-ratchet-doublecount.md | 1 + .../ratchet_testing/common_ratchets.py | 4 +- .../imbue_common/ratchet_testing/ratchets.py | 31 +- .../imbue/imbue_common/sentry/core.py | 23 + .../imbue/imbue_common/sentry/core_test.py | 30 + vendor/mngr/libs/mngr/CHANGELOG.md | 2 + vendor/mngr/libs/mngr/UNABRIDGED_CHANGELOG.md | 18 + .../changelog/mngr-add-dockview-profiles.md | 5 + vendor/mngr/libs/mngr/changelog/mngr-fable.md | 1 + .../libs/mngr/changelog/mngr-fct-rename.md | 3 - .../mngr/changelog/mngr-fix-flaky-ci-tests.md | 7 + .../mngr/libs/mngr/changelog/mngr-mngr-msg.md | 1 + .../mngr-revive-done-agents-on-message.md | 9 + .../mngr/docs/commands/secondary/forward.md | 1 + .../mngr/docs/commands/secondary/latchkey.md | 5 +- .../mngr/docs/commands/secondary/message.md | 2 +- vendor/mngr/libs/mngr/docs/concepts/agents.md | 4 +- vendor/mngr/libs/mngr/imbue/mngr/api/find.py | 57 ++ .../mngr/libs/mngr/imbue/mngr/api/message.py | 31 +- .../libs/mngr/imbue/mngr/api/message_test.py | 152 ++++- .../mngr/libs/mngr/imbue/mngr/cli/conftest.py | 20 + .../libs/mngr/imbue/mngr/cli/extras_test.py | 51 +- .../mngr/libs/mngr/imbue/mngr/cli/message.py | 2 +- vendor/mngr/libs/mngr/imbue/mngr/cli/start.py | 28 +- .../libs/mngr/imbue/mngr/cli/test_extras.py | 26 + .../mngr/libs/mngr/imbue/mngr/cli/testing.py | 22 + .../libs/mngr/imbue/mngr/cli/transcript.py | 14 + .../mngr/imbue/mngr/cli/transcript_test.py | 21 + .../mngr/libs/mngr/imbue/mngr/hosts/common.py | 25 +- .../libs/mngr/imbue/mngr/hosts/common_test.py | 43 +- .../mngr/libs/mngr/imbue/mngr/hosts/host.py | 55 +- .../libs/mngr/imbue/mngr/hosts/host_test.py | 26 - .../libs/mngr/imbue/mngr/hosts/outer_host.py | 85 ++- .../mngr/imbue/mngr/hosts/outer_host_test.py | 140 +++- .../mngr/interfaces/provider_instance.py | 19 + .../mngr/libs/mngr/imbue/mngr/primitives.py | 8 +- .../imbue/mngr/providers/docker/instance.py | 27 +- .../docker/test_docker_integration.py | 48 +- .../imbue/mngr/providers/docker/volume.py | 13 + .../libs/mngr/imbue/mngr/resources/Dockerfile | 2 +- .../libs/mngr/imbue/mngr/utils/logging.py | 23 + .../imbue/mngr/utils/thread_cleanup_test.py | 8 + .../libs/mngr_claude/changelog/mngr-fable.md | 1 + .../mngr_claude/imbue/mngr_claude/plugin.py | 8 + .../imbue/mngr_claude/plugin_test.py | 36 + vendor/mngr/libs/mngr_forward/CHANGELOG.md | 2 + .../libs/mngr_forward/UNABRIDGED_CHANGELOG.md | 10 + .../changelog/mngr-fix-http-2-noise.md | 1 + .../mngr_forward/imbue/mngr_forward/cli.py | 185 +++++- .../imbue/mngr_forward/cli_test.py | 220 +++++- .../imbue/mngr_forward/resolver.py | 44 +- .../imbue/mngr_forward/resolver_test.py | 74 +++ .../mngr_forward/imbue/mngr_forward/server.py | 57 +- .../imbue/mngr_forward/server_test.py | 143 ++++ .../imbue/mngr_forward/service_map_cache.py | 77 +++ .../mngr_forward/service_map_cache_test.py | 91 +++ .../imbue/mngr_forward/test_ratchets.py | 22 +- .../mngr_forward/imbue/mngr_forward/tls.py | 114 ++++ .../imbue/mngr_forward/tls_test.py | 85 +++ vendor/mngr/libs/mngr_forward/pyproject.toml | 3 +- .../mngr/libs/mngr_imbue_cloud/CHANGELOG.md | 3 + .../mngr_imbue_cloud/UNABRIDGED_CHANGELOG.md | 20 + .../changelog/mngr-fct-rename.md | 3 - .../mngr_imbue_cloud/providers/instance.py | 133 +++- .../providers/instance_test.py | 243 ++++++- vendor/mngr/libs/mngr_latchkey/CHANGELOG.md | 3 + vendor/mngr/libs/mngr_latchkey/README.md | 6 + .../mngr_latchkey/UNABRIDGED_CHANGELOG.md | 22 + .../hynek-bump-latchkey-to-2.20.2.md | 1 + .../mngr_latchkey/imbue/mngr_latchkey/cli.py | 69 +- .../mngr_latchkey/imbue/mngr_latchkey/core.py | 50 +- .../imbue/mngr_latchkey/core_test.py | 89 +++ .../imbue/mngr_latchkey/discovery.py | 10 +- .../mngr_latchkey/extensions/services.json | 8 +- .../imbue/mngr_latchkey/remote_gateway.py | 626 +++++++++++++----- .../mngr_latchkey/remote_gateway_test.py | 289 +++++--- .../imbue/mngr_latchkey/sentry.py | 6 + .../scripts/generate_services_json.py | 1 + vendor/mngr/libs/mngr_lima/CHANGELOG.md | 1 + .../libs/mngr_lima/UNABRIDGED_CHANGELOG.md | 4 + .../changelog/gabriel-roaring-cobra.md | 3 + .../libs/mngr_lima/imbue/mngr_lima/errors.py | 48 +- .../mngr_lima/imbue/mngr_lima/instance.py | 47 +- .../imbue/mngr_lima/instance_test.py | 50 ++ .../libs/mngr_lima/imbue/mngr_lima/limactl.py | 159 +++-- .../mngr_lima/imbue/mngr_lima/limactl_test.py | 110 ++- .../libs/mngr_lima/imbue/mngr_lima/testing.py | 12 + .../changelog/mngr-add-dockview-profiles.md | 1 + .../imbue/mngr_modal/test_snapshot.py | 6 + .../mngr_robinhood/changelog/mngr-fable.md | 1 + .../mngr_robinhood/_agent_sdk/pricing.py | 7 + .../mngr_robinhood/_agent_sdk/pricing_test.py | 1 + .../libs/mngr_ttyd/UNABRIDGED_CHANGELOG.md | 6 + .../mngr_ttyd/changelog/mngr-fct-rename.md | 3 - .../libs/mngr_usage/changelog/mngr-fable.md | 1 + .../mngr_usage/imbue/mngr_usage/pricing.py | 7 + .../libs/mngr_vps/UNABRIDGED_CHANGELOG.md | 6 + .../mngr_vps/changelog/mngr-fct-rename.md | 3 - vendor/mngr/litellm_proxy/config.yaml | 10 + .../mngr/scripts/snapshot_minds_e2e_state.py | 23 +- .../specs/common-transcript-standard/spec.md | 24 +- vendor/mngr/uv.lock | 42 +- 166 files changed, 6021 insertions(+), 790 deletions(-) create mode 100644 vendor/mngr/apps/minds/changelog/gabriel-archetypal-mouse.md create mode 100644 vendor/mngr/apps/minds/changelog/hynek-bump-latchkey-to-2.20.2.md delete mode 100644 vendor/mngr/apps/minds/changelog/mngr-fct-rename.md create mode 100644 vendor/mngr/apps/minds/changelog/mngr-inline-fn-ratchet-doublecount.md create mode 100644 vendor/mngr/apps/minds/electron/chrome-crashed.html create mode 100644 vendor/mngr/apps/minds/electron/crashed.html create mode 100644 vendor/mngr/apps/minds/electron/log-rotation.js create mode 100644 vendor/mngr/apps/minds/electron/logger.js create mode 100644 vendor/mngr/apps/minds/imbue/minds/primitives_test.py create mode 100644 vendor/mngr/apps/minds/test/unit/log-rotation.test.js create mode 100644 vendor/mngr/apps/modal_litellm/changelog/mngr-fable.md delete mode 100644 vendor/mngr/apps/remote_service_connector/changelog/mngr-fct-rename.md create mode 100644 vendor/mngr/blueprint/dockview-named-layouts/plan-dockview-named-layouts.md create mode 100644 vendor/mngr/blueprint/electron-log-and-crash-page/plan-electron-log-and-crash-page.md create mode 100644 vendor/mngr/blueprint/forward-services-cache/plan-forward-services-cache.md create mode 100644 vendor/mngr/blueprint/imbue-cloud-sticky-agent-labels/plan-imbue-cloud-sticky-agent-labels.md create mode 100644 vendor/mngr/blueprint/mngr-forward-http2/plan-mngr-forward-http2.md create mode 100644 vendor/mngr/dev/changelog/gabriel-archetypal-mouse.md create mode 100644 vendor/mngr/dev/changelog/mngr-add-dockview-profiles.md create mode 100644 vendor/mngr/dev/changelog/mngr-fable.md delete mode 100644 vendor/mngr/dev/changelog/mngr-fct-rename.md create mode 100644 vendor/mngr/dev/changelog/mngr-mngr-msg.md create mode 100644 vendor/mngr/libs/imbue_common/changelog/mngr-inline-fn-ratchet-doublecount.md create mode 100644 vendor/mngr/libs/mngr/changelog/mngr-add-dockview-profiles.md create mode 100644 vendor/mngr/libs/mngr/changelog/mngr-fable.md delete mode 100644 vendor/mngr/libs/mngr/changelog/mngr-fct-rename.md create mode 100644 vendor/mngr/libs/mngr/changelog/mngr-fix-flaky-ci-tests.md create mode 100644 vendor/mngr/libs/mngr/changelog/mngr-mngr-msg.md create mode 100644 vendor/mngr/libs/mngr/changelog/mngr-revive-done-agents-on-message.md create mode 100644 vendor/mngr/libs/mngr/imbue/mngr/cli/test_extras.py create mode 100644 vendor/mngr/libs/mngr_claude/changelog/mngr-fable.md create mode 100644 vendor/mngr/libs/mngr_forward/changelog/mngr-fix-http-2-noise.md create mode 100644 vendor/mngr/libs/mngr_forward/imbue/mngr_forward/service_map_cache.py create mode 100644 vendor/mngr/libs/mngr_forward/imbue/mngr_forward/service_map_cache_test.py create mode 100644 vendor/mngr/libs/mngr_forward/imbue/mngr_forward/tls.py create mode 100644 vendor/mngr/libs/mngr_forward/imbue/mngr_forward/tls_test.py delete mode 100644 vendor/mngr/libs/mngr_imbue_cloud/changelog/mngr-fct-rename.md create mode 100644 vendor/mngr/libs/mngr_latchkey/changelog/hynek-bump-latchkey-to-2.20.2.md create mode 100644 vendor/mngr/libs/mngr_lima/changelog/gabriel-roaring-cobra.md create mode 100644 vendor/mngr/libs/mngr_modal/changelog/mngr-add-dockview-profiles.md create mode 100644 vendor/mngr/libs/mngr_robinhood/changelog/mngr-fable.md delete mode 100644 vendor/mngr/libs/mngr_ttyd/changelog/mngr-fct-rename.md create mode 100644 vendor/mngr/libs/mngr_usage/changelog/mngr-fable.md delete mode 100644 vendor/mngr/libs/mngr_vps/changelog/mngr-fct-rename.md diff --git a/vendor/mngr/.github/actions/tmr-setup/action.yml b/vendor/mngr/.github/actions/tmr-setup/action.yml index b4d58b9f..beea522c 100644 --- a/vendor/mngr/.github/actions/tmr-setup/action.yml +++ b/vendor/mngr/.github/actions/tmr-setup/action.yml @@ -23,7 +23,7 @@ runs: - name: Install Claude Code CLI shell: bash - run: npm install -g @anthropic-ai/claude-code@2.1.160 + run: npm install -g @anthropic-ai/claude-code@2.1.207 - name: Configure git identity shell: bash diff --git a/vendor/mngr/.github/workflows/release-tests.yml b/vendor/mngr/.github/workflows/release-tests.yml index f728cbf4..ad72d96b 100644 --- a/vendor/mngr/.github/workflows/release-tests.yml +++ b/vendor/mngr/.github/workflows/release-tests.yml @@ -66,7 +66,7 @@ jobs: run: uv sync --all-packages - name: Install Claude Code CLI - run: npm install -g @anthropic-ai/claude-code@2.1.160 + run: npm install -g @anthropic-ai/claude-code@2.1.207 - name: Run Docker release tests run: PYTEST_MAX_DURATION_SECONDS=900 uv run pytest -n 0 --timeout 600 --no-cov --cov-fail-under=0 -v --tb=short --ignore apps/minds -m '(docker or docker_sdk) and release' @@ -117,7 +117,7 @@ jobs: run: brew install tmux unison - name: Install Claude Code CLI - run: npm install -g @anthropic-ai/claude-code@2.1.160 + run: npm install -g @anthropic-ai/claude-code@2.1.207 - name: Configure Docker Hub mirror if: runner.os == 'Linux' diff --git a/vendor/mngr/apps/minds/CHANGELOG.md b/vendor/mngr/apps/minds/CHANGELOG.md index 3ed4d45d..16b3cf70 100644 --- a/vendor/mngr/apps/minds/CHANGELOG.md +++ b/vendor/mngr/apps/minds/CHANGELOG.md @@ -8,6 +8,7 @@ For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDG ### Added +- Added: Docker compute provider picks its container runtime per platform. The create form gained an advanced "Container runtime" setting (runc vs runsc) that defaults to runc on macOS (where gVisor is unavailable) and runsc on Linux, and can be overridden per workspace — macOS users no longer need the `MNGR__PROVIDERS__DOCKER__DOCKER_RUNTIME=runc` env-var workaround. Selecting runsc stacks a new `docker_runsc` create-template overlay on top of the shared `docker` template, so the gVisor choice is the only difference between the two runtimes. - Added: Settings page gains a left nav with a Permissions section (Connectors, File sharing, Workspace delegation) that inspects and revokes agent access across all workspaces. Revoking removes only the permission rule; saved sign-ins stay so agents can reconnect later. - Added: Released minds v0.3.6 — rolls up all mngr/minds changes that landed on `main` since v0.3.5; pins `FALLBACK_BRANCH` at the `minds-v0.3.6` forever-claude-template tag. - Added: `minds server list` / `minds server prep` — env-aware wrappers around `mngr imbue_cloud admin server {list,prep}` that resolve the host_pool DSN from the activated env and inject the tier's `POOL_SSH_PRIVATE_KEY` from Vault, so operators no longer need to hand-export those values to inspect or (re-)prep bare-metal boxes. @@ -98,6 +99,7 @@ For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDG ### Changed +- Changed: forever-claude-template renamed to default-workspace-template. `DEFAULT_WORKSPACE_TEMPLATE_GIT_URL` replaces the old constant, the `fct_worktree` module is now `default_workspace_template_worktree` (`FctWorktreeError` -> `DefaultWorkspaceTemplateWorktreeError`, `FctTemplateRef` -> `DefaultWorkspaceTemplateRef`), and the `just sync-vendor-mngr` env var `FCT_DIR` is now `DEFAULT_WORKSPACE_TEMPLATE_DIR` (the recipe errors with a rename hint when only the old var is set). - Changed: `mngr imbue_cloud` sharing calls and other one-shot backend `mngr` invocations (Cloudflare tunnel-token injection/removal, in-workspace `git version` reads) now route through the shared pre-warmed `MngrCaller`, removing the multi-second interpreter/plugin-import cost per call. - Changed: Bootstrap now writes all four `[providers.aws-]` blocks regardless of AWS credentials, so a credential-less region surfaces a discovery error in the providers panel instead of being silently absent; panel-toggled `is_enabled` is preserved across rewrites. - Changed: Workspace permission-request dialog splits grantable actions into "General permissions" (apply across all workspaces, e.g. listing/reading and creating) and "Workspace-specific permissions" (act on individual workspaces, e.g. destroy, start/stop, SSH). The workspace-specific group shows a scope choice ("Only this workspace" vs "All workspaces" when a target is named; a pre-selected "All workspaces" with a caution notice otherwise). Removes the previous confusion where non-workspace-specific checkboxes appeared alongside a per-workspace target choice that did not affect them. @@ -238,6 +240,10 @@ For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDG ### Fixed +- Fixed: Workspace UI hang once enough streaming tabs were open in one workspace. The workspace origin was served over plain HTTP/1.1 which Chromium caps at ~6 held-open connections per origin, so a workspace with that many long-lived streams (one SSE per open chat tab, plus any `/service/` app's own streams) queued every further request indefinitely. Minds now runs `mngr forward` with `--use-http2` so the workspace origin terminates TLS and negotiates HTTP/2 (which multiplexes many streams over one connection); the Electron shell trusts the proxy's ephemeral self-signed cert for its loopback origins (`localhost`, `*.localhost`, `127.0.0.1`) in the workspace-content session only, and every real https origin still gets Chromium's normal verification. The minds bare-origin backend (Home/Create/chrome/sidebar and its `minds_session` cookie) stays plain HTTP. +- Fixed: Recovery page no longer renders a restart verdict for a workspace whose host could not be observed during discovery. Previously an unreachable imbue_cloud host surfaced as CRASHED, which the recovery classifier read as trusted "container is down" and auto-dispatched an unattended host restart doomed by construction (the restart routes over the same outer SSH that is unreachable). With imbue_cloud now surfacing unreachable hosts as UNKNOWN, the dispatch-tier classifier treats a host state that answers neither "running" nor "offline" as non-evidence and shows the live "Reconnecting" state. The consent-gated HOST_UNRESPONSIVE verdict is reserved for containers observed running but unreachable inside (a RUNNING claim whose in-container exec cleanly failed, or the UNAUTHENTICATED state); HOST_OFFLINE (unattended restart) still requires an actual observation that the container is stopped. +- Fixed: Minds backend now tells Sentry to ignore the paramiko/pyinfra stdlib loggers. The backend brokers cross-workspace reverse SSH tunnels through paramiko, so handled SSH connection-failure noise (logged at ERROR by paramiko's transport thread) would otherwise be captured by Sentry's default logging integration and flood the project with un-rate-limited events. Genuine, actionable failures still reach Sentry as typed exceptions logged through loguru. +- Fixed: Discovery-health watchdog's producer-stall threshold raised from 35s to 180s so a merely-slow-but-alive producer no longer trips it. The old value sat only a few seconds above the worst-case healthy cadence between discovery snapshots (~150s with a single slow provider), triggering spurious supervisor bounces/restarts — each of which re-provisions every managed host. - Fixed: Permission-request dialogs opened from a notification now close the whole inbox after Approve/Deny instead of advancing to the next pending request (which could surface an unrelated stale request from another agent). Opening the full inbox via the Requests button still advances through pending requests as before. - Fixed: `minds.log` no longer floods with repeated "Discovery error from ..." warnings — provider-level discovery errors log once per process with an info-level recovery when discovery next succeeds. Host- and agent-attributed errors are unaffected. - Fixed: The `[providers.modal]` config block's panel-toggled `is_enabled = false` is no longer reset back to enabled on every startup. diff --git a/vendor/mngr/apps/minds/UNABRIDGED_CHANGELOG.md b/vendor/mngr/apps/minds/UNABRIDGED_CHANGELOG.md index 4c74fee1..9bd4e8dd 100644 --- a/vendor/mngr/apps/minds/UNABRIDGED_CHANGELOG.md +++ b/vendor/mngr/apps/minds/UNABRIDGED_CHANGELOG.md @@ -4,6 +4,54 @@ Full, unedited changelog entries consolidated nightly from individual files in ` For a concise summary, see [CHANGELOG.md](CHANGELOG.md). +## 2026-07-13 + +Serve the workspace UI over HTTP/2 to fix the hang that occurred once enough streaming tabs were open in one workspace. + +The whole workspace origin was previously served over plain HTTP/1.1, which Chromium caps at ~6 held-open connections per origin. Once a workspace had that many long-lived streams (one SSE per open chat tab, plus any `/service/` app's own streams), the pool was exhausted and every further request -- including the plain GETs that bootstrap a new terminal or app iframe -- queued indefinitely, hanging the UI even though the backend was healthy. + +Minds now runs its single `mngr forward` proxy with `--use-http2`, so the workspace origin terminates TLS and negotiates HTTP/2 (which multiplexes many streams over one connection). The Python readiness/recovery probes and the `/goto/` redirect URLs flip to `https` in lockstep, and the Electron shell builds `https`/`wss` workspace URLs, marks the `mngr_forward_session` cookie `Secure`, and silently trusts the proxy's ephemeral self-signed cert for its loopback origins (`localhost`, `*.localhost`, `127.0.0.1`) in the workspace-content session only -- every real https origin still gets Chromium's normal verification. The minds bare-origin backend (Home/Create/chrome/sidebar and its `minds_session` cookie) stays plain HTTP; the app deliberately runs a mixed http/https transport, which is not mixed-content-blocked. + +The recovery page no longer renders a restart verdict for a workspace whose host could not be observed during discovery. Previously, an unreachable imbue_cloud host surfaced as CRASHED, which the recovery classifier read as a trusted "container is down" observation: it classified HOST_OFFLINE and auto-dispatched an unattended host restart -- a restart that is doomed by construction, because restarting routes over the same outer SSH that is unreachable. + +With the imbue_cloud provider now surfacing unreachable hosts as UNKNOWN, the dispatch-tier classifier treats a host state that answers neither "running" nor "offline" (UNKNOWN, transitional states like STARTING, or an absent host state) as non-evidence, the same way it treats a timed-out probe: it classifies INDETERMINATE, so the page shows the live "Reconnecting" state, keeps checking, and offers no restart -- returning the user to the workspace automatically the moment it answers. + +The consent-gated HOST_UNRESPONSIVE verdict is now reserved for containers that were actually observed running but cannot be reached inside: an observed RUNNING claim whose in-container exec cleanly failed, and the UNAUTHENTICATED state, which providers emit for a running container whose inner SSH is unreachable (e.g. its sshd died). For that dead-sshd case the consent-gated host restart is the engineered recovery (its stop step is not skipped, so the stop/start relaunches sshd) -- preserving the behavior introduced for the docker provider in PR #2247. HOST_OFFLINE (unattended restart) still requires an actual observation that the container is stopped. + +Known remaining ambiguity, still deferred (originally flagged in PR #2247): imbue_cloud also reuses UNAUTHENTICATED for an outer-SSH authentication rejection, where the offered restart fails on the same auth error. Distinguishing "running but inner SSH dead" from "outer auth rejected" needs a dedicated provider state (e.g. UNREACHABLE), which is a provider-vocabulary change spanning imbue_cloud, docker, mngr_wait, and docs. + +- The Docker compute provider now picks its container runtime per platform + instead of always using gVisor (`runsc`). The create form gained an advanced + "Container runtime" setting (runc vs runsc) that defaults to runc on macOS + (where gVisor is unavailable) and runsc on Linux, and can be overridden per + workspace. macOS users no longer need the + `MNGR__PROVIDERS__DOCKER__DOCKER_RUNTIME=runc` environment-variable workaround + to create a local Docker workspace. + +- Under the hood, selecting runsc stacks a new `docker_runsc` create-template + overlay (in default-workspace-template) on top of the shared `docker` template, + so the gVisor choice is the only difference between the two runtimes and the + runc path -- the default -- is now what runs on macOS. + +- The create-form/API runtime default now honors a `MINDS_DOCKER_RUNTIME_DEFAULT` + environment override. It is unset in real deployments (so Linux still defaults + to runsc) and is set to `RUNC` by the e2e/snapshot test paths, whose Docker + daemon has no gVisor -- this is the layer that decides whether the create + stacks `docker_runsc` at all, which the mngr provider-config env var cannot + undo once the template is explicitly stacked. + +The minds backend now tells Sentry to ignore the paramiko/pyinfra stdlib loggers. The backend brokers cross-workspace reverse SSH tunnels through the same paramiko machinery, so handled SSH connection-failure noise logged at ERROR by paramiko would otherwise be captured by Sentry's default logging integration and flood the project with un-rate-limited events. Genuine, actionable failures still reach Sentry as typed exceptions logged through loguru. + +Increased the discovery-health watchdog's producer-stall threshold from 35s to 180s. The previous value sat only a few seconds above the healthy interval between discovery snapshots (mngr's default per-provider poll interval is 30s, and a single slow-but-alive provider can legitimately take up to ~150s between snapshots), so a merely-slow producer could be misread as stalled and trigger spurious supervisor bounces/restarts (each of which re-provisions every managed host). The new threshold clears the worst-case healthy cadence, so only a producer emitting literally nothing trips the watchdog, as intended. Also corrected the accompanying inline documentation, which incorrectly described the discovery cadence as ~10s. + +## 2026-07-11 + +The forever-claude-template repo is being renamed to default-workspace-template (with the `fct`/`FCT` shorthand expanded to `default_workspace_template`/`DEFAULT_WORKSPACE_TEMPLATE` forms). + +The default template git URL constant is now `DEFAULT_WORKSPACE_TEMPLATE_GIT_URL`, the `fct_worktree` module is renamed to `default_workspace_template_worktree` (`FctWorktreeError` becomes `DefaultWorkspaceTemplateWorktreeError`, `FctTemplateRef` becomes `DefaultWorkspaceTemplateRef`), and the `FCT_DIR` env var read by `just sync-vendor-mngr` is now `DEFAULT_WORKSPACE_TEMPLATE_DIR` (the recipe errors with a rename hint when only the old var is set). Docs and skills reference the new repo name. + +Removed the stale-SSH-port workaround from `test_backup_enable_repair_and_destination_change_on_resumed_workspace` (the `docker stop` + host-side `mngr start` heal that ran before the first host-side connection). The docker provider now reconciles the recorded SSH port against the container's live mapping on every connection (PR #2395), so the first `mngr exec` into the raw-`docker start`-resumed workspace heals the host record on its own. + ## 2026-07-10 Permission-request dialogs now dismiss the whole inbox window after you Approve or Deny, unless you intentionally opened the full inbox via the Requests button. Previously, resolving a request that you had opened from a notification (or that auto-opened when it arrived) would advance to the next pending request, which could surprise you by suddenly surfacing an unrelated, stale request from another agent. Opening the whole inbox with the Requests button still advances through pending requests as before. diff --git a/vendor/mngr/apps/minds/changelog/gabriel-archetypal-mouse.md b/vendor/mngr/apps/minds/changelog/gabriel-archetypal-mouse.md new file mode 100644 index 00000000..6f45f93c --- /dev/null +++ b/vendor/mngr/apps/minds/changelog/gabriel-archetypal-mouse.md @@ -0,0 +1,13 @@ +Persist the Electron main process's logs and recover gracefully when a workspace view's renderer crashes. + +- The Electron main process now tees all of its console output (and any uncaught exception / unhandled rejection) into a new `~/.minds/logs/electron.log`, so main-process problems are durably diagnosable instead of vanishing in packaged builds. + +- Both `electron.log` and the backend's `minds.log` now rotate at 100MB (keeping the 10 newest rotations, gzipped) instead of growing without bound. + +- Bug reports upload the current `minds.log` and `electron.log` plus the most recent gzipped rotation of each, under accurate names (`backend_logs` / `electron_logs`), and a report filed while the backend is down now attaches both logs. + +- When a workspace's content view crashes (e.g. its renderer is killed over a long sleep), the app now shows an "Aw, Snap!"-style crash page with the crash reason and a Reload button, rather than a blank white screen that only a manual Home-and-back would fix. Reload is manual (no auto-reload) to avoid crash loops. The page uses a white background with the distressed Minds head logo and a "Bummer" heading. + +- The window's other two views now recover from renderer death too: if the chrome (titlebar) renderer dies it shows a compact in-titlebar error strip with a Reload button (leaving the workspace content running) instead of a blank bar, and if the overlay/menu renderer dies it is silently reloaded so the next sidebar/inbox/help open works. + +- Out-of-memory renderer deaths (`oom`, in any of the three views) are now reported to Sentry automatically (subject to the existing error-reporting opt-in) and labeled by which view died, so these failures are visible without waiting for a manual bug report. Native renderer crashes already flow to Sentry as minidumps, and sleep/external kills (`killed`) are deliberately left unreported (unactionable and noisy) but remain in `electron.log`. diff --git a/vendor/mngr/apps/minds/changelog/hynek-bump-latchkey-to-2.20.2.md b/vendor/mngr/apps/minds/changelog/hynek-bump-latchkey-to-2.20.2.md new file mode 100644 index 00000000..3e16d5fa --- /dev/null +++ b/vendor/mngr/apps/minds/changelog/hynek-bump-latchkey-to-2.20.2.md @@ -0,0 +1 @@ +Update Latchkey to include support for GitHub's GraphQL API. diff --git a/vendor/mngr/apps/minds/changelog/mngr-fct-rename.md b/vendor/mngr/apps/minds/changelog/mngr-fct-rename.md deleted file mode 100644 index 1e93875d..00000000 --- a/vendor/mngr/apps/minds/changelog/mngr-fct-rename.md +++ /dev/null @@ -1,3 +0,0 @@ -The forever-claude-template repo is being renamed to default-workspace-template (with the `fct`/`FCT` shorthand expanded to `default_workspace_template`/`DEFAULT_WORKSPACE_TEMPLATE` forms). - -The default template git URL constant is now `DEFAULT_WORKSPACE_TEMPLATE_GIT_URL`, the `fct_worktree` module is renamed to `default_workspace_template_worktree` (`FctWorktreeError` becomes `DefaultWorkspaceTemplateWorktreeError`, `FctTemplateRef` becomes `DefaultWorkspaceTemplateRef`), and the `FCT_DIR` env var read by `just sync-vendor-mngr` is now `DEFAULT_WORKSPACE_TEMPLATE_DIR` (the recipe errors with a rename hint when only the old var is set). Docs and skills reference the new repo name. diff --git a/vendor/mngr/apps/minds/changelog/mngr-inline-fn-ratchet-doublecount.md b/vendor/mngr/apps/minds/changelog/mngr-inline-fn-ratchet-doublecount.md new file mode 100644 index 00000000..aa71714a --- /dev/null +++ b/vendor/mngr/apps/minds/changelog/mngr-inline-fn-ratchet-doublecount.md @@ -0,0 +1 @@ +Re-trimmed the inline-function ratchet count from 9 to 7 after a shared-checker bug fix stopped double-counting a doubly-nested function. No minds source changed; the two removed counts were the same function counted once per enclosing function. diff --git a/vendor/mngr/apps/minds/electron/backend.js b/vendor/mngr/apps/minds/electron/backend.js index 87b5e94e..2d92a8bb 100644 --- a/vendor/mngr/apps/minds/electron/backend.js +++ b/vendor/mngr/apps/minds/electron/backend.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const paths = require('./paths'); const { getBuildMetadata } = require('./build-metadata'); +const { createRotatingLogStream } = require('./log-rotation'); // Swallow EPIPE on the Electron main process's own stdout/stderr. When dev // launches go through a pipe (e.g. `just minds-start | head -30`), the @@ -199,7 +200,11 @@ function startBackend(onProgress, onNotification, onAuthEvent, onMngrForwardStar fs.mkdirSync(logDir, { recursive: true }); const logFile = path.join(logDir, 'minds.log'); - const logStream = fs.createWriteStream(logFile, { flags: 'a' }); + // Rotate + gzip minds.log like the other logs (it was previously an + // unbounded append stream that grew to hundreds of MB). Same write/end + // surface as the old fs.createWriteStream, so the callers below are + // unchanged. + const logStream = createRotatingLogStream({ filePath: logFile }); onProgress('Starting Minds...'); diff --git a/vendor/mngr/apps/minds/electron/chrome-crashed.html b/vendor/mngr/apps/minds/electron/chrome-crashed.html new file mode 100644 index 00000000..2c0fb596 --- /dev/null +++ b/vendor/mngr/apps/minds/electron/chrome-crashed.html @@ -0,0 +1,80 @@ + + + + + + Menu bar crashed + + + +
+ The menu bar stopped responding. + +
+ + + diff --git a/vendor/mngr/apps/minds/electron/content-relay-preload.js b/vendor/mngr/apps/minds/electron/content-relay-preload.js index 1f282a43..78daeea0 100644 --- a/vendor/mngr/apps/minds/electron/content-relay-preload.js +++ b/vendor/mngr/apps/minds/electron/content-relay-preload.js @@ -50,6 +50,14 @@ window.addEventListener('message', (event) => { ipcRenderer.send('open-help', typeof agentId === 'string' ? agentId : ''); return; } + // Crash page (crashed.html) Reload button: ask the main process to re-load the + // workspace URL that was showing when this content view's renderer died, + // spawning a fresh renderer. No payload -- the main process holds the pre-crash + // URL, so a foreign page can't smuggle a navigation target through this channel. + if (data.type === 'minds:reload-crashed-view') { + ipcRenderer.send('reload-crashed-view'); + return; + } // Create-screen sign-in: open the shared modal overlay loaded with the // sign-in page (so it covers the whole window, including the title bar). // No payload -- the main process builds the fixed `/auth/signin-modal` URL. diff --git a/vendor/mngr/apps/minds/electron/crashed.html b/vendor/mngr/apps/minds/electron/crashed.html new file mode 100644 index 00000000..e96a4e19 --- /dev/null +++ b/vendor/mngr/apps/minds/electron/crashed.html @@ -0,0 +1,167 @@ + + + + + + Workspace crashed + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Bummer

+

This workspace view stopped responding. Your work in the workspace is safe — reload to reconnect.

+
+
+ + +
+ + + diff --git a/vendor/mngr/apps/minds/electron/log-rotation.js b/vendor/mngr/apps/minds/electron/log-rotation.js new file mode 100644 index 00000000..e8172068 --- /dev/null +++ b/vendor/mngr/apps/minds/electron/log-rotation.js @@ -0,0 +1,201 @@ +// Size-based log rotation for the Electron-written log files (electron.log and +// minds.log). Mirrors the Python jsonl sink's scheme (imbue_common/logging.py): +// 100MB threshold, keep the 10 newest rotated files, timestamp-suffixed names. +// Unlike the Python sink, rotated files are gzipped after rotating (so error +// reports can upload the current file plus the most recent rotation gzip), and +// no cross-process lock is needed -- only the Electron main process writes these +// files. +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); +const { pipeline } = require('stream'); + +// 100MB, matching the jsonl sink so all of minds' logs behave consistently. +const DEFAULT_MAX_SIZE_BYTES = 100 * 1024 * 1024; +const DEFAULT_MAX_ROTATED_COUNT = 10; + +// The compressed suffix appended to a rotated file once gzipped. +const ROTATED_SUFFIX = '.gz'; + +/** + * Timestamp suffix for a rotated file: ``YYYYMMDDHHMMSSmmm`` in UTC. + * + * Fixed-width so a lexicographic sort of file names is chronological. Mirrors + * the Python sink's ```` shape; JS ``Date`` only resolves + * to milliseconds, so the sub-second field is 3 digits rather than 6 -- still + * fixed-width, so sorting is unaffected. ``now`` is injectable for tests. + */ +function rotationTimestamp(now) { + const d = now || new Date(); + const pad = (value, width) => String(value).padStart(width, '0'); + return ( + pad(d.getUTCFullYear(), 4) + + pad(d.getUTCMonth() + 1, 2) + + pad(d.getUTCDate(), 2) + + pad(d.getUTCHours(), 2) + + pad(d.getUTCMinutes(), 2) + + pad(d.getUTCSeconds(), 2) + + pad(d.getUTCMilliseconds(), 3) + ); +} + +/** + * Remove the oldest gzipped rotations of ``baseName`` in ``dir``, keeping at + * most ``maxRotatedCount``. Only ``..gz`` files are considered; + * a transient un-gzipped rotation (between the rename and the gzip completing) + * is left alone. Fixed-width timestamps make a name sort chronological. + */ +function pruneRotated(dir, baseName, maxRotatedCount) { + let names; + try { + names = fs.readdirSync(dir); + } catch { + return; + } + const prefix = baseName + '.'; + const rotated = names + .filter((name) => name.startsWith(prefix) && name.endsWith(ROTATED_SUFFIX)) + .sort(); + const excess = rotated.length - maxRotatedCount; + for (let i = 0; i < excess; i++) { + try { + fs.unlinkSync(path.join(dir, rotated[i])); + } catch { + // Raced/removed/permission-denied -- skip. + } + } +} + +/** + * Gzip a just-rotated raw file in the background, then remove the raw file and + * prune old rotations. Done off the main path (streamed, not gzipSync) so the + * main process never blocks compressing a ~100MB file. Best-effort: a failure + * leaves the raw rotation on disk (still uploadable) rather than crashing. + */ +function compressRotatedFile(rawPath, dir, baseName, maxRotatedCount) { + const gzPath = rawPath + ROTATED_SUFFIX; + // pipeline (unlike a bare .pipe() chain) destroys every stream -- freeing the + // read + write file descriptors -- on both success and error, so a gzip failure + // can't leak fds over the long-lived main process's many rotations. + pipeline(fs.createReadStream(rawPath), zlib.createGzip(), fs.createWriteStream(gzPath), (err) => { + if (err) { + console.warn(`[log-rotation] failed to gzip ${rawPath}: ${err && err.message}`); + return; + } + fs.unlink(rawPath, () => {}); + pruneRotated(dir, baseName, maxRotatedCount); + }); +} + +/** + * Open an append-mode, size-rotating log sink at ``filePath``. + * + * Returns ``{ write(chunk), end() }``. Backed by an async ``fs.WriteStream`` + * (matching the non-blocking log stream this replaced -- writes never block the + * Electron main thread). When the tracked size reaches ``maxSizeBytes`` the + * stream is ended (which flushes all buffered writes and closes the fd) and only + * THEN is the file renamed to ``.`` -- so the background gzip + * can never read a partially-flushed file. A fresh stream is opened, the rotated + * file is gzipped and pruned to ``maxRotatedCount`` newest, and any lines that + * arrived mid-rotation are replayed. ``end()`` returns a promise that resolves + * once the current stream has flushed. + */ +function createRotatingLogStream(options) { + const filePath = options.filePath; + const maxSizeBytes = options.maxSizeBytes || DEFAULT_MAX_SIZE_BYTES; + const maxRotatedCount = options.maxRotatedCount || DEFAULT_MAX_ROTATED_COUNT; + const dir = path.dirname(filePath); + const baseName = path.basename(filePath); + + fs.mkdirSync(dir, { recursive: true }); + pruneRotated(dir, baseName, maxRotatedCount); + + // Never throw from an async stream 'error' (that would crash the main process), + // and never route it through console.* (which is teed back into a log stream) -- + // write it straight to stderr. + const attachErrorHandler = (writeStream) => { + writeStream.on('error', (err) => { + try { + process.stderr.write(`[log-rotation] write stream error for ${filePath}: ${err && err.message}\n`); + } catch { + // Nothing more we can do. + } + }); + }; + + const openStream = () => { + const writeStream = fs.createWriteStream(filePath, { flags: 'a' }); + attachErrorHandler(writeStream); + return writeStream; + }; + + let stream = openStream(); + let size = 0; + try { + size = fs.statSync(filePath).size; + } catch { + size = 0; + } + let isRotating = false; + // Lines that arrive after the old stream starts closing but before the fresh + // one opens are buffered here and replayed once rotation completes. + const pendingDuringRotation = []; + + const rotate = () => { + isRotating = true; + const rotating = stream; + // end()'s callback fires after all buffered writes have flushed and the fd is + // closed; renaming only then guarantees the gzip reads a complete file. + rotating.end(() => { + try { + const rawPath = filePath + '.' + rotationTimestamp(); + fs.renameSync(filePath, rawPath); + compressRotatedFile(rawPath, dir, baseName, maxRotatedCount); + } catch (err) { + console.warn(`[log-rotation] rotation failed for ${filePath}: ${err && err.message}`); + } + stream = openStream(); + size = 0; + isRotating = false; + const buffered = pendingDuringRotation.splice(0); + for (const chunk of buffered) writeChunk(chunk); + }); + }; + + function writeChunk(text) { + if (isRotating) { + pendingDuringRotation.push(text); + return; + } + if (size >= maxSizeBytes) { + pendingDuringRotation.push(text); + rotate(); + return; + } + stream.write(text); + size += Buffer.byteLength(text); + } + + return { + write(chunk) { + writeChunk(typeof chunk === 'string' ? chunk : String(chunk)); + }, + end() { + return new Promise((resolve) => { + try { + stream.end(resolve); + } catch { + resolve(); + } + }); + }, + }; +} + +module.exports = { + createRotatingLogStream, + rotationTimestamp, + pruneRotated, + DEFAULT_MAX_SIZE_BYTES, + DEFAULT_MAX_ROTATED_COUNT, +}; diff --git a/vendor/mngr/apps/minds/electron/logger.js b/vendor/mngr/apps/minds/electron/logger.js new file mode 100644 index 00000000..50088db7 --- /dev/null +++ b/vendor/mngr/apps/minds/electron/logger.js @@ -0,0 +1,86 @@ +// Durable logging for the Electron MAIN process. +// +// The main process previously logged only to stdout/stderr via bare console.* +// calls, which vanish in packaged builds -- so a main-process problem (e.g. a +// content-view renderer dying over sleep) left nothing on disk to diagnose. +// This module tees every console.log/warn/error into ~/.minds/logs/electron.log +// (rotated + gzipped like the other logs) and records uncaught exceptions / +// unhandled rejections, so those failures are durably diagnosable and uploadable +// with bug reports. Initialize this as the very first thing in main.js (before +// initSentry) so startup output is captured. +// +// Why not the `electron-log` library: it is the usual pick for durable Electron +// logging, but it does not gzip its rotations and names them differently, and we +// specifically want on-disk gzipped rotations under fixed, exact names +// (`electron.log` / `electron.log..gz`, mirroring the backend's `minds.log` +// scheme) so the Sentry `LogAttachmentGroup` globs in utils/sentry/core.py can +// upload the current file plus the newest rotation with no extra transform. The +// shared electron/log-rotation.js gives us exactly that and mirrors the Python +// jsonl sink's 100MB/keep-10 behavior, so a small, purpose-fit helper is +// preferable to adapting a heavier general-purpose dependency here. +const path = require('path'); +const util = require('util'); +const paths = require('./paths'); +const { createRotatingLogStream } = require('./log-rotation'); + +let logStream = null; +// Reentrancy guard against a log write synchronously re-entering writeLine. +// console.* is wrapped below to call back into writeLine, so if logStream.write +// ever emitted a console.* call on the same tick, writeLine -> logStream.write -> +// console.* -> writeLine would recurse until the stack overflows and the main +// process crashes. The guard makes the nested call a no-op; the wrapped console.* +// still reaches stdout/stderr via the wrapper's original(...), so nothing is lost. +// (log-rotation.js defers its own rename-failure console.warn into an async stream +// callback, where it is buffered rather than recursed, so this guard is defensive.) +let isWritingLine = false; + +/** ``LEVEL`` + ISO-8601 UTC timestamp prefix, matching console formatting semantics via util.format. */ +function formatLine(level, args) { + return `${new Date().toISOString()} [${level}] ${util.format(...args)}\n`; +} + +function writeLine(level, args) { + if (!logStream || isWritingLine) return; + isWritingLine = true; + try { + logStream.write(formatLine(level, args)); + } catch { + // A broken log sink must never disturb the caller. + } finally { + isWritingLine = false; + } +} + +/** + * Tee console output into electron.log and record fatal main-process failures. + * + * Idempotent: a second call is a no-op. Wrapping console preserves the original + * stdout/stderr behavior (dev terminals are unchanged) and adds the file tee. + * Uncaught exceptions are observed via ``uncaughtExceptionMonitor`` -- a Node + * primitive that logs WITHOUT counting as a handler, so Sentry's capture and the + * process's exit behavior are left exactly as they were. Unhandled rejections + * are logged alongside Sentry's own handling (Sentry already registers a + * listener, so adding a logging one changes neither the exit decision nor the + * capture). + */ +function initElectronLogging() { + if (logStream) return; + logStream = createRotatingLogStream({ filePath: path.join(paths.getLogDir(), 'electron.log') }); + + for (const level of ['log', 'warn', 'error']) { + const original = console[level].bind(console); + console[level] = (...args) => { + writeLine(level.toUpperCase(), args); + original(...args); + }; + } + + process.on('uncaughtExceptionMonitor', (err, origin) => { + writeLine('UNCAUGHT', [`(${origin})`, err && err.stack ? err.stack : err]); + }); + process.on('unhandledRejection', (reason) => { + writeLine('UNHANDLED_REJECTION', [reason && reason.stack ? reason.stack : reason]); + }); +} + +module.exports = { initElectronLogging }; diff --git a/vendor/mngr/apps/minds/electron/main.js b/vendor/mngr/apps/minds/electron/main.js index 65321097..a0b79b63 100644 --- a/vendor/mngr/apps/minds/electron/main.js +++ b/vendor/mngr/apps/minds/electron/main.js @@ -3,18 +3,27 @@ const todesktop = require('@todesktop/runtime'); const path = require('path'); const fs = require('fs'); const paths = require('./paths'); +const { initElectronLogging } = require('./logger'); const { initSentry, captureManualReport, isLogInclusionEnabled } = require('./sentry'); const { runEnvSetup } = require('./env-setup'); const { startBackend, shutdown, getBackendProcess } = require('./backend'); const { decideStartupRoute } = require('./startup-routing'); const { computeBundleViewBounds } = require('./view-layout'); +// Tee console output into ~/.minds/logs/electron.log and record uncaught +// main-process failures BEFORE anything else runs, so startup output (including +// Sentry init and any early crash) is durably captured on disk. +initElectronLogging(); + // Initialize Sentry as early as possible so errors thrown during main-process // startup (window creation, env setup, backend spawn) are captured. The SDK is // always initialized but only sends when the user has enabled error reporting // (the report_unexpected_errors setting, read live per event) -- see -// electron/sentry.js. -initSentry(); +// electron/sentry.js. ``rendererNameForWebContents`` labels renderer-death events +// by which of the window's three views died (it's a hoisted declaration, so +// referencing it here before its definition is fine; it's only invoked at crash +// time, long after ``bundles`` is populated). +initSentry({ getRendererName: rendererNameForWebContents }); // Only init the auto-updater in packaged builds: in dev, electron.autoUpdater // is undefined on macOS, so todesktop's constructor throws. @@ -81,6 +90,15 @@ const CONTENT_INSET = 4; const CONTENT_CORNER_RADIUS = 12; const CONTENT_PARTITION = 'persist:workspace-content'; +// Local crash page shown in the content view when its renderer process dies. +const CRASHED_PAGE_FILE = path.join(__dirname, 'crashed.html'); + +// Local crash page shown in the chrome (titlebar) view when its renderer process +// dies. Unlike the content crash page it is a compact strip: only the top ~38px +// of the chrome view is visible (the content view overlays the rest), so the page +// anchors its message + Reload button to that titlebar-height band. +const CHROME_CRASHED_PAGE_FILE = path.join(__dirname, 'chrome-crashed.html'); + // Coalesce rapid SSE-triggered list refreshes when the inbox modal is open. A // burst of requests events (count 1 -> 2 -> 3 within a few ms) would otherwise // re-post the chrome-event multiple times in flight; the inbox shell would @@ -194,6 +212,18 @@ function toAbsoluteUrl(url) { return url; } +// Whether ``url`` is our local content-view crash page (crashed.html). The +// navigation handlers use this to skip treating the crash page as real workspace +// content (which would clobber the pre-crash URL / workspace id we need to reload). +function isCrashPageUrl(url) { + if (!url || !url.startsWith('file://')) return false; + try { + return decodeURIComponent(new URL(url).pathname).endsWith('/crashed.html'); + } catch { + return false; + } +} + // Classify a URL as "external" -- i.e. something that should open in the // user's default browser rather than inside the app. All in-app navigation // (the minds backend, the mngr_forward plugin, and every @@ -263,6 +293,23 @@ function getBundleFromEvent(event) { return null; } +// Map a webContents to the name of the bundle view it backs +// (``chrome`` / ``content`` / ``modal``), or undefined if it isn't one of ours. +// Passed to Sentry.init as ``getRendererName`` so the childProcess integration's +// renderer-death events are labeled by which view died. A renderer that is gone +// is not yet destroyed, so its stable ``id`` is still readable here. +function rendererNameForWebContents(contents) { + if (!contents) return undefined; + const id = contents.id; + for (const b of bundles) { + if (b.window.isDestroyed()) continue; + if (b.chromeView && !b.chromeView.webContents.isDestroyed() && b.chromeView.webContents.id === id) return 'chrome'; + if (b.contentView && !b.contentView.webContents.isDestroyed() && b.contentView.webContents.id === id) return 'content'; + if (b.modalView && !b.modalView.webContents.isDestroyed() && b.modalView.webContents.id === id) return 'modal'; + } + return undefined; +} + function getMostRecentWindow() { for (const b of mruWindows) { if (!b.window.isDestroyed()) return b; @@ -604,6 +651,16 @@ function createBundle() { // a restored window re-derives it from its saved content URL. currentAccentAgentId: null, preErrorUrl: null, + // Content-view renderer-crash recovery. ``isContentCrashed`` is true while + // the local crash page (crashed.html) is showing in the content view; + // ``crashedFromUrl`` is the workspace URL that was displayed when the + // renderer died, re-loaded when the user clicks Reload on the crash page. + isContentCrashed: false, + crashedFromUrl: null, + // Chrome (titlebar) view renderer-crash recovery. True while the compact + // chrome-crashed.html strip is showing in the chrome view; cleared when the + // user clicks Reload (reloadCrashedChromeView) and the chrome reloads /_chrome. + isChromeCrashed: false, isErrorState: false, isLoadingState: true, isQuittingState: false, @@ -625,6 +682,37 @@ function createBundle() { primeViewWithCachedChromeState(bundle, chromeView.webContents); }); + // When the chrome (titlebar) view's renderer dies -- it runs in a separate + // process from the content view and can be reaped independently over a long + // sleep -- Electron leaves a blank, dead titlebar with no recovery affordance. + // Show a compact local crash strip with a Reload button instead. Only the top + // ~38px of the chrome view is visible (the content view overlays the rest), so + // chrome-crashed.html anchors its UI to that band. Never navigate synchronously + // inside this handler (electron#19887); defer to a later tick. Mirrors the + // content view's render-process-gone handling (wireContentViewEvents). + chromeView.webContents.on('render-process-gone', (_e, details) => { + const reason = details && details.reason; + const exitCode = details && typeof details.exitCode === 'number' ? details.exitCode : null; + // A clean exit is intentional teardown (window close), not a crash. + if (reason === 'clean-exit') return; + if (isShuttingDown || bundle.window.isDestroyed()) return; + // The full-app error takeover owns the chrome view (it loads shell.html there) + // and drives its own retry; don't fight it with a titlebar crash strip. + if (bundle.isErrorState) return; + if (bundle.chromeView !== chromeView || chromeView.webContents.isDestroyed()) return; + console.error(`[chrome-crash] chrome (titlebar) view renderer gone (reason=${reason}, exitCode=${exitCode})`); + bundle.isChromeCrashed = true; + setImmediate(() => { + if (bundle.window.isDestroyed()) return; + if (bundle.chromeView !== chromeView || chromeView.webContents.isDestroyed()) return; + // A real reload (or another crash) since then superseded this. + if (!bundle.isChromeCrashed) return; + chromeView.webContents.loadFile(CHROME_CRASHED_PAGE_FILE).catch((err) => { + console.error('[chrome-crash] failed to load crash page:', err && err.message); + }); + }); + }); + wireContentViewEvents(bundle, contentView); registerShortcutsFor(bundle, chromeView.webContents); registerShortcutsFor(bundle, contentView.webContents); @@ -649,6 +737,14 @@ function wireContentViewEvents(bundle, contentView) { }); const onContentNavigate = (url, httpResponseCode) => { + // Loading the local crash page is not a real content navigation: skip all + // state updates so ``currentContentUrl`` / ``currentWorkspaceId`` keep + // pointing at the pre-crash workspace (which Reload re-loads). + if (isCrashPageUrl(url)) return; + // A genuine navigation clears any pending crash state (e.g. the user hit + // Home from the crash page, or Reload succeeded). + bundle.isContentCrashed = false; + bundle.crashedFromUrl = null; if (!bundle.isErrorState) { bundle.currentContentUrl = url; bundle.preErrorUrl = url; @@ -698,6 +794,49 @@ function wireContentViewEvents(bundle, contentView) { contentView.webContents.on('did-navigate', (_e, url, httpResponseCode) => onContentNavigate(url, httpResponseCode)); contentView.webContents.on('did-navigate-in-page', (_e, url) => onContentNavigate(url)); + // When the content view's renderer process dies (e.g. killed by the OS over a + // long sleep, or an OOM), Electron leaves the view painting its pinned-white + // background with no way to recover except manual Home-and-back navigation. + // Show a local crash page instead, with a Reload button. We never navigate + // synchronously inside this handler -- loadURL/loadFile here can crash the + // whole app (electron#19887) -- so the navigation is deferred to a later tick. + contentView.webContents.on('render-process-gone', (_e, details) => { + const reason = details && details.reason; + const exitCode = details && typeof details.exitCode === 'number' ? details.exitCode : null; + // A clean exit is an intentional teardown (window close, error-takeover + // view destroy, navigation swap), not a crash -- ignore it. + if (reason === 'clean-exit') return; + if (isShuttingDown || bundle.window.isDestroyed()) return; + // The full-app error takeover owns the screen and tears the content view + // down itself; don't fight it with a content-only crash page. + if (bundle.isErrorState) return; + if (bundle.contentView !== contentView || contentView.webContents.isDestroyed()) return; + console.error( + `[content-crash] workspace content view renderer gone ` + + `(reason=${reason}, exitCode=${exitCode}, url=${bundle.currentContentUrl || 'unknown'})` + ); + bundle.isContentCrashed = true; + bundle.crashedFromUrl = bundle.currentContentUrl; + const crashedAgentId = bundle.currentWorkspaceId || ''; + setImmediate(() => { + if (bundle.window.isDestroyed()) return; + if (bundle.contentView !== contentView || contentView.webContents.isDestroyed()) return; + // A real navigation (or another crash) since the crash superseded this. + if (!bundle.isContentCrashed) return; + contentView.webContents + .loadFile(CRASHED_PAGE_FILE, { + query: { + reason: reason || 'unknown', + exitCode: exitCode === null ? '' : String(exitCode), + workspace: crashedAgentId, + }, + }) + .catch((err) => { + console.error('[content-crash] failed to load crash page:', err && err.message); + }); + }); + }); + // Enforce workspace uniqueness at the Electron level so it applies to EVERY // path that can drive the content view to a /forwarding/X/ URL (landing-page // row clicks, in-page anchors, pushState, etc.), not just sidebar-driven @@ -1036,6 +1175,26 @@ function createBundleOverlayView(bundle) { } catch { /* noop */ } } }); + // When the overlay host's renderer dies (it runs in a separate process and can + // be reaped over a long sleep like the other views), there is nothing visible to + // recover -- the overlay is hidden whenever no modal is open. So we just reset any + // open-modal state and silently reload the warm host, so the next sidebar / inbox / + // help open lands on a fresh page instead of a dead one. Defer the reload a tick + // (never navigate synchronously in this handler -- electron#19887). + modal.webContents.on('render-process-gone', (_e, details) => { + const reason = details && details.reason; + if (reason === 'clean-exit') return; + if (isShuttingDown || bundle.window.isDestroyed()) return; + if (bundle.modalView !== modal || modal.webContents.isDestroyed()) return; + const exitCode = details && typeof details.exitCode === 'number' ? details.exitCode : null; + console.error(`[overlay-crash] overlay host view renderer gone (reason=${reason}, exitCode=${exitCode})`); + if (bundle.modalVisible) closeModal(bundle); + setImmediate(() => { + if (bundle.window.isDestroyed()) return; + if (bundle.modalView !== modal || modal.webContents.isDestroyed()) return; + loadOverlayHost(bundle); + }); + }); // Auto-open DevTools for dev-time inspection, matching the content view; gated // on the same env var so a single switch covers all surfaces. if (process.env.MINDS_OPEN_DEVTOOLS === '1') { @@ -1266,6 +1425,39 @@ function loadUrlIntoBundleContentView(bundle, url) { } } +// Re-load the workspace URL that was showing when the content view's renderer +// crashed (spawning a fresh renderer). Falls back to Home if the pre-crash URL +// is unknown. Driven by the crash page's Reload button via the reload-crashed-view +// IPC relay. Stateless: if the reload crashes again, the crash page reappears. +function reloadCrashedContentView(bundle) { + if (!bundle || bundle.window.isDestroyed()) return; + if (!bundle.contentView || bundle.contentView.webContents.isDestroyed()) return; + const target = bundle.crashedFromUrl || (backendBaseUrl ? backendBaseUrl + '/' : null); + if (!target) return; + // Clear crash state before loading so the ensuing did-navigate is processed + // as a normal content navigation. + bundle.isContentCrashed = false; + bundle.crashedFromUrl = null; + loadUrlIntoBundleContentView(bundle, target); +} + +// Reload the chrome (titlebar) view after its renderer crashed, spawning a fresh +// renderer. Driven by chrome-crashed.html's Reload button via the reload-chrome +// IPC. Reloads /_chrome (or shell.html before the backend is up, matching the +// normal chrome-load choice); the chrome view's did-finish-load re-primes it from +// the cached chrome state, so the bar returns fully populated. Stateless: if the +// reload crashes again, the crash strip reappears. +function reloadCrashedChromeView(bundle) { + if (!bundle || bundle.window.isDestroyed()) return; + if (!bundle.chromeView || bundle.chromeView.webContents.isDestroyed()) return; + bundle.isChromeCrashed = false; + if (backendBaseUrl) { + bundle.chromeView.webContents.loadURL(backendBaseUrl + '/_chrome'); + } else { + bundle.chromeView.webContents.loadFile(path.join(__dirname, 'shell.html')); + } +} + function openOrFocusWorkspace(agentId, url) { const existing = findBundleForWorkspace(agentId); if (existing) { @@ -2981,6 +3173,31 @@ function handleNotification(event) { // // Mirrors the cookie into the workspace content partition so any // chrome/iframe / WebContentsView using that partition is authenticated too. +// Loopback hosts the `mngr forward` proxy serves under its self-signed cert: +// the bare `localhost` origin, the `agent-.localhost` workspace subdomains +// (`*.localhost`), and the `127.0.0.1` IP host. Every other host must fall +// through to Chromium's normal verification. +function isLoopbackHostname(hostname) { + return hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '127.0.0.1'; +} + +// Trust the proxy's ephemeral self-signed cert for its loopback origins, in the +// workspace-content partition only. The proxy regenerates the cert every +// startup and only minds' own loopback origins use it, so there is no OS trust +// store or CA to install; every real https origin still gets Chromium's default +// verification (cb(-3)). Registered only when the proxy actually serves TLS. +function trustLoopbackCertsForWorkspaceContent() { + const contentSession = session.fromPartition(CONTENT_PARTITION); + contentSession.setCertificateVerifyProc((request, callback) => { + if (isLoopbackHostname(request.hostname)) { + callback(0); // trust + } else { + callback(-3); // defer to Chromium's default verification + } + }); + console.log('[startup] Trusting self-signed loopback certs for workspace content (HTTP/2 on)'); +} + async function handleMngrForwardStarted(event) { const port = event.mngr_forward_port; const preauth = event.preauth_cookie; @@ -2988,10 +3205,13 @@ async function handleMngrForwardStarted(event) { console.warn('[startup] mngr_forward_started missing port or preauth_cookie:', event); return; } - const url = `http://localhost:${port}`; + // The proxy serves TLS + HTTP/2, so the origin is https and the session + // cookie must be Secure (a Secure cookie is only sent over https). + const url = `https://localhost:${port}`; // Cache the plugin origin so workspaceUrlForAgent() can build /goto/ URLs // against the correct port (the plugin, not minds). mngrForwardBaseUrl = url; + trustLoopbackCertsForWorkspaceContent(); const baseSpec = { url, name: 'mngr_forward_session', @@ -2999,6 +3219,7 @@ async function handleMngrForwardStarted(event) { httpOnly: true, sameSite: 'lax', path: '/', + secure: true, }; try { await session.defaultSession.cookies.set(baseSpec); @@ -3162,6 +3383,22 @@ ipcMain.on('open-help', (event, agentId) => { openHelp(getBundleFromEvent(event), scopedAgentId); }); +// Reload the crashed content view on behalf of the crash page (crashed.html). +// Only content-relay-preload.js can emit this channel, and only for an +// allowlisted `minds:reload-crashed-view` postMessage. No payload -- the target +// URL is the shell's own record of the pre-crash workspace, never the renderer's. +ipcMain.on('reload-crashed-view', (event) => { + reloadCrashedContentView(getBundleFromEvent(event)); +}); + +// Reload the crashed chrome (titlebar) view on behalf of chrome-crashed.html's +// Reload button. The chrome view runs the trusted first-party preload bridge, so +// this is a direct channel (no content-relay indirection). No payload -- the +// reload target is the shell's own /_chrome, never anything from the renderer. +ipcMain.on('reload-chrome', (event) => { + reloadCrashedChromeView(getBundleFromEvent(event)); +}); + // Open the sign-in modal in the shared overlay on behalf of the (otherwise // unprivileged) workspace content view -- the create screen posts an // allowlisted `minds:open-signin-modal` when a signed-out user presses diff --git a/vendor/mngr/apps/minds/electron/preload.js b/vendor/mngr/apps/minds/electron/preload.js index 79044ffa..4f19e965 100644 --- a/vendor/mngr/apps/minds/electron/preload.js +++ b/vendor/mngr/apps/minds/electron/preload.js @@ -127,6 +127,9 @@ contextBridge.exposeInMainWorld('minds', { // Actions retry: () => ipcRenderer.send('retry'), openLogFile: () => ipcRenderer.send('open-log-file'), + // Reload the chrome (titlebar) view after its renderer crashed -- the Reload + // button on the local chrome-crashed.html strip. + reloadChrome: () => ipcRenderer.send('reload-chrome'), // Window controls minimize: () => ipcRenderer.send('window-minimize'), diff --git a/vendor/mngr/apps/minds/electron/pyproject/uv.lock b/vendor/mngr/apps/minds/electron/pyproject/uv.lock index 5436fb85..506c1991 100644 --- a/vendor/mngr/apps/minds/electron/pyproject/uv.lock +++ b/vendor/mngr/apps/minds/electron/pyproject/uv.lock @@ -112,6 +112,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -673,6 +706,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "hypercorn" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" }, +] + [[package]] name = "hyperframe" version = "6.1.0" @@ -696,23 +744,29 @@ name = "imbue-common" version = "0.1.20" source = { editable = "../../../../libs/imbue_common" } dependencies = [ + { name = "boto3" }, { name = "click" }, { name = "cowsay-python" }, { name = "httpx" }, { name = "loguru" }, { name = "pydantic" }, + { name = "sentry-sdk" }, { name = "tenacity" }, + { name = "traceback-with-variables" }, ] [package.metadata] requires-dist = [ + { name = "boto3", specifier = ">=1.34" }, { name = "click", specifier = ">=8.0" }, { name = "cowsay-python", specifier = ">=1.0.2" }, { name = "httpx", specifier = ">=0.27" }, { name = "inline-snapshot", marker = "extra == 'dev'", specifier = ">=0.13" }, { name = "loguru", specifier = ">=0.7" }, { name = "pydantic", specifier = ">=2.0" }, + { name = "sentry-sdk", specifier = ">=2.59.0" }, { name = "tenacity", specifier = ">=8.0" }, + { name = "traceback-with-variables", specifier = ">=2.0" }, ] provides-extras = ["dev"] @@ -828,8 +882,10 @@ dependencies = [ { name = "click" }, { name = "click-option-group" }, { name = "concurrency-group" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "httpx" }, + { name = "hypercorn" }, { name = "imbue-common" }, { name = "imbue-mngr" }, { name = "itsdangerous" }, @@ -838,7 +894,6 @@ dependencies = [ { name = "paramiko" }, { name = "pluggy" }, { name = "pydantic" }, - { name = "uvicorn" }, { name = "websockets" }, ] @@ -847,8 +902,10 @@ requires-dist = [ { name = "click" }, { name = "click-option-group" }, { name = "concurrency-group", editable = "../../../../libs/concurrency_group" }, + { name = "cryptography", specifier = ">=42.0" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "hypercorn", specifier = ">=0.17" }, { name = "imbue-common", editable = "../../../../libs/imbue_common" }, { name = "imbue-mngr", editable = "../../../../libs/mngr" }, { name = "itsdangerous", specifier = ">=2.1" }, @@ -857,7 +914,6 @@ requires-dist = [ { name = "paramiko", specifier = ">=3.0" }, { name = "pluggy" }, { name = "pydantic", specifier = ">=2.0" }, - { name = "uvicorn", specifier = ">=0.30" }, { name = "websockets", specifier = ">=13" }, ] @@ -1161,12 +1217,14 @@ name = "minds" version = "0.1.0" source = { editable = "../../" } dependencies = [ + { name = "argon2-cffi" }, { name = "boto3" }, { name = "botocore" }, { name = "cheroot" }, { name = "click" }, { name = "click-option-group" }, { name = "concurrency-group" }, + { name = "cryptography" }, { name = "flask" }, { name = "httpx" }, { name = "imbue-common" }, @@ -1174,7 +1232,6 @@ dependencies = [ { name = "imbue-mngr-aws" }, { name = "imbue-mngr-forward" }, { name = "imbue-mngr-latchkey" }, - { name = "imbue-mngr-ovh" }, { name = "imbue-mngr-vps" }, { name = "itsdangerous" }, { name = "jinja2" }, @@ -1186,6 +1243,7 @@ dependencies = [ { name = "psycopg2-binary" }, { name = "pydantic" }, { name = "sentry-sdk" }, + { name = "spectree" }, { name = "telethon" }, { name = "tenacity" }, { name = "tomlkit" }, @@ -1196,12 +1254,14 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "argon2-cffi", specifier = ">=23.1" }, { name = "boto3", specifier = ">=1.34" }, { name = "botocore", specifier = ">=1.34" }, { name = "cheroot", specifier = ">=10.0" }, { name = "click", specifier = ">=8.0" }, { name = "click-option-group", specifier = ">=0.5.6" }, { name = "concurrency-group", editable = "../../../../libs/concurrency_group" }, + { name = "cryptography", specifier = ">=42.0" }, { name = "flask", specifier = ">=3.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "imbue-common", editable = "../../../../libs/imbue_common" }, @@ -1209,7 +1269,6 @@ requires-dist = [ { name = "imbue-mngr-aws", editable = "../../../../libs/mngr_aws" }, { name = "imbue-mngr-forward", editable = "../../../../libs/mngr_forward" }, { name = "imbue-mngr-latchkey", editable = "../../../../libs/mngr_latchkey" }, - { name = "imbue-mngr-ovh", editable = "../../../../libs/mngr_ovh" }, { name = "imbue-mngr-vps", editable = "../../../../libs/mngr_vps" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "jinja2", specifier = ">=3.1.0" }, @@ -1221,6 +1280,7 @@ requires-dist = [ { name = "psycopg2-binary", specifier = ">=2.9" }, { name = "pydantic", specifier = ">=2.0" }, { name = "sentry-sdk", specifier = ">=2.59.0" }, + { name = "spectree", specifier = ">=1.4" }, { name = "telethon", specifier = ">=1.40.0" }, { name = "tenacity" }, { name = "tomlkit", specifier = ">=0.12.0" }, @@ -1248,6 +1308,7 @@ dependencies = [ { name = "imbue-mngr-vps" }, { name = "minds" }, { name = "modal-proxy" }, + { name = "overlay" }, { name = "resource-guards" }, ] @@ -1267,6 +1328,7 @@ requires-dist = [ { name = "imbue-mngr-vps", editable = "../../../../libs/mngr_vps" }, { name = "minds", editable = "../../" }, { name = "modal-proxy", editable = "../../../../libs/modal_proxy" }, + { name = "overlay", editable = "../../../../libs/overlay" }, { name = "resource-guards", editable = "../../../../libs/resource_guards" }, ] @@ -1465,6 +1527,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -1876,6 +1947,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "spectree" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3d/1380581a90b714a56cb1669982e5889557b6edcda85e9ee3d538804f4726/spectree-2.0.1.tar.gz", hash = "sha256:280dd6beb10a9c5f023254650d3d8f4494077d556f7de92c1fbebd1203efe39c", size = 58970, upload-time = "2025-12-16T01:38:52.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/9c/889999a8e8c1c9c56b55f20038e807e234199887b3ba706bd708b57ce9d8/spectree-2.0.1-py3-none-any.whl", hash = "sha256:fd30dadca93afdd82573d886365ae27f0b3eea4ed43a9daf6f6616483520b673", size = 44438, upload-time = "2025-12-16T01:38:51.011Z" }, +] + [[package]] name = "starlette" version = "1.0.0" @@ -2058,19 +2141,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/f8/5fcf2602662dbd6fab8f60f24efb363fb9ec0e9fa37d7a6ae6c8785d6e14/urwid-4.0.0-py3-none-any.whl", hash = "sha256:f6f0381d5656b8b24ee960969927021d858c1f7320771cf8f3ec5b94a139b9b7", size = 293823, upload-time = "2026-03-30T12:39:00.293Z" }, ] -[[package]] -name = "uvicorn" -version = "0.43.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/62/f2/368268300fb8af33743508d738ef7bb4d56afdb46c6d9c0fa3dd515df171/uvicorn-0.43.0.tar.gz", hash = "sha256:ab1652d2fb23abf124f36ccc399828558880def222c3cb3d98d24021520dc6e8", size = 85686, upload-time = "2026-04-03T18:37:48.984Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl", hash = "sha256:46fac64f487fd968cd999e5e49efbbe64bd231b5bd8b4a0b482a23ebce499620", size = 68591, upload-time = "2026-04-03T18:37:47.64Z" }, -] - [[package]] name = "watchdog" version = "6.0.0" @@ -2179,6 +2249,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bb/8e/04fb92513f4deab0f9bf4bdeeebc74f12d4de75ff00ad213c69983fc6563/WsgiDAV-4.3.3-py3-none-any.whl", hash = "sha256:8d96b0f05ad7f280572e99d1c605962a853d715f8e934298555d0c47ef275e88", size = 164954, upload-time = "2024-05-04T18:27:57.718Z" }, ] +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + [[package]] name = "yarl" version = "1.23.0" diff --git a/vendor/mngr/apps/minds/electron/sentry.js b/vendor/mngr/apps/minds/electron/sentry.js index 6e7973a5..988f1100 100644 --- a/vendor/mngr/apps/minds/electron/sentry.js +++ b/vendor/mngr/apps/minds/electron/sentry.js @@ -115,7 +115,7 @@ function fixupReleaseId(releaseId) { * account settings) takes effect without restarting the app. Call this as early * as possible in main.js so startup errors are captured once reporting is on. */ -function initSentry() { +function initSentry(options = {}) { const environment = resolveEnvironment(); const dsn = dsnForEnvironment(environment); const { releaseId, gitSha } = getBuildMetadata(); @@ -123,6 +123,10 @@ function initSentry() { dsn, environment, release: fixupReleaseId(releaseId), + // Label renderer-death events by which window view died (chrome/content/modal). + // The caller (main.js) supplies the mapping since it owns the window registry; + // undefined is fine (the childProcess integration falls back to "renderer"). + getRendererName: options.getRendererName, // Drop the native-crash (Crashpad) integration when the dev launcher asks // us to (it sets MINDS_DISABLE_CRASHPAD; see the `start` script in // package.json). Crashpad's `crashReporter.start()` spawns a @@ -134,10 +138,32 @@ function initSentry() { // quit. Keying off the launcher's flag (not packaged-ness) keeps native-crash // minidump reporting on for every other run -- packaged builds and a plain // `electron .` alike. - integrations: (defaults) => - process.env.MINDS_DISABLE_CRASHPAD === '1' - ? defaults.filter((integration) => integration.name !== 'SentryMinidump') - : defaults, + integrations: (defaults) => { + const kept = + process.env.MINDS_DISABLE_CRASHPAD === '1' + ? defaults.filter((integration) => integration.name !== 'SentryMinidump') + : defaults; + // Replace the default ChildProcess integration so an out-of-memory renderer + // death is captured as a Sentry EVENT, not just a breadcrumb. Its default + // ``events`` list is ['abnormal-exit', 'launch-failed', 'integrity-failure'] + // -- none of which produce a Crashpad minidump -- so an OOM'd renderer (e.g. a + // workspace view dying under memory pressure) is otherwise never reported. We + // add only 'oom': it does not produce a minidump either, so it's a unique + // signal. We deliberately do NOT add: + // * 'crashed' -- a native renderer crash already produces a minidump event + // via the SentryMinidump integration above (that's why the SDK default + // omits it); adding it here would double-report every crash in prod. + // * 'killed' -- dominated by OS/sleep reaping and external kills we can't + // act on, so it stays breadcrumb-only (still on disk in electron.log and + // attached to manual reports). + return kept.map((integration) => + integration.name === 'ChildProcess' + ? Sentry.childProcessIntegration({ + events: ['abnormal-exit', 'launch-failed', 'integrity-failure', 'oom'], + }) + : integration + ); + }, // Error reporting only -- no performance tracing (matches the backend). tracesSampleRate: 0, // Keep PII out of reports, matching the backend's send_default_pii=False. @@ -232,21 +258,41 @@ function newestFileMatching(dir, predicate) { return newest; } +/** + * The named log file ``name`` in ``dir`` as a candidate, or null when it is + * absent / not a regular file. Used to attach specific logs by exact name + * (rather than newest-by-suffix) so a backend-down report always carries both + * the backend's minds.log AND the Electron main-process electron.log. + */ +function namedFile(dir, name) { + const filePath = path.join(dir, name); + try { + if (!fs.statSync(filePath).isFile()) return null; + return { filePath, name }; + } catch { + return null; + } +} + /** * Gzip the tails of the most relevant log files into Sentry attachments: the - * live Python backend log (``*.jsonl``, written by the now-dead backend but - * still on disk) and the Electron main-process log (``*.log``). Returns [] when - * the logs directory is unreadable. Best-effort per file -- a file that can't be - * read (or that would breach the compressed-attachment budget) is skipped rather - * than aborting the whole report. + * backend log (``minds.log``, the backend's stdout/stderr, written by the shell + * even when the backend has died), the Electron main-process log + * (``electron.log``), and the live Python backend jsonl log. Returns [] when the + * logs directory is unreadable. Best-effort per file -- a file that can't be read + * (or that would breach the compressed-attachment budget) is skipped rather than + * aborting the whole report. */ function collectLogAttachments() { const logDir = paths.getLogDir(); - // The live backend log is exactly ``*.jsonl``; rotated logs are ``*.jsonl.``. - // Matching the bare suffix keeps us on the live file rather than a rotated one. + // minds.log and electron.log are attached by exact name (a backend-down report + // wants BOTH -- why the backend died and what the shell did about it). The live + // backend jsonl log is exactly ``*.jsonl``; rotated logs are ``*.jsonl.``, + // so matching the bare suffix keeps us on the live file rather than a rotated one. const candidates = [ + namedFile(logDir, 'minds.log'), + namedFile(logDir, 'electron.log'), newestFileMatching(logDir, (name) => name.endsWith('.jsonl')), - newestFileMatching(logDir, (name) => name.endsWith('.log')), ]; const attachments = []; let totalCompressedBytes = 0; diff --git a/vendor/mngr/apps/minds/imbue/minds/cli/run.py b/vendor/mngr/apps/minds/imbue/minds/cli/run.py index 576b23f8..418518a3 100644 --- a/vendor/mngr/apps/minds/imbue/minds/cli/run.py +++ b/vendor/mngr/apps/minds/imbue/minds/cli/run.py @@ -460,7 +460,7 @@ def run( f"{_MNGR_FORWARD_LISTEN_TIMEOUT_SECONDS:.0f}s; the plugin likely failed to start. " "Check the logs above for its stderr and retry." ) - logger.info(" mngr forward: http://127.0.0.1:{}", mngr_forward_port) + logger.info(" mngr forward: https://127.0.0.1:{}", mngr_forward_port) # AgentCreator is constructed *after* ``start_mngr_forward`` so the # readiness probe can use the same preauth cookie the plugin accepts and diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/agent_creator.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/agent_creator.py index ccf043e9..abc93331 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/agent_creator.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/agent_creator.py @@ -63,6 +63,7 @@ from imbue.minds.primitives import AIProvider from imbue.minds.primitives import BackupProvider from imbue.minds.primitives import CreationId +from imbue.minds.primitives import DockerRuntime from imbue.minds.primitives import GitBranch from imbue.minds.primitives import GitUrl from imbue.minds.primitives import LaunchMode @@ -94,6 +95,11 @@ # assumption about which app that is or which routes it implements. _WORKSPACE_PROBE_PATH: Final[str] = "/" +# Scheme of the `mngr forward` proxy origin. minds always runs the proxy with +# `--use-http2`, so it terminates TLS and the probe/redirect URLs the Python +# side builds are always `https`. +_MNGR_FORWARD_SCHEME: Final[str] = "https" + def make_workspace_probe_client(preauth_cookie: str, probe_timeout_seconds: float) -> httpx.Client: """Construct a reusable httpx.Client preconfigured for workspace probes. @@ -101,11 +107,17 @@ def make_workspace_probe_client(preauth_cookie: str, probe_timeout_seconds: floa Callers that probe in a tight poll loop should construct one of these and pass it to ``probe_workspace_through_plugin`` on each iteration, instead of letting the helper construct a one-shot client per call. + + The proxy serves TLS (HTTP/2), so cert verification is disabled: these + probes dial ``127.0.0.1`` with a ``Host: agent-.localhost`` header, so + hostname verification could never pass, and the cert is a self-signed + ephemeral one the probe is not positioned to validate anyway. Loopback-only. """ return httpx.Client( timeout=probe_timeout_seconds, follow_redirects=False, cookies={_MNGR_FORWARD_SESSION_COOKIE_NAME: preauth_cookie}, + verify=False, ) @@ -151,7 +163,7 @@ def probe_workspace_through_plugin( one-shot client is constructed for this single probe -- fine for one-off / sporadic callers but wasteful in a loop. """ - probe_url = f"http://127.0.0.1:{mngr_forward_port}{_WORKSPACE_PROBE_PATH}" + probe_url = f"{_MNGR_FORWARD_SCHEME}://127.0.0.1:{mngr_forward_port}{_WORKSPACE_PROBE_PATH}" host_header = f"{agent_id}.localhost" if client is not None: return _probe_once(client, probe_url, host_header) @@ -581,6 +593,7 @@ def _build_mngr_create_command( region: str | None = None, latchkey_env: Mapping[str, str] | None = None, color: str | None = None, + docker_runtime: DockerRuntime = DockerRuntime.RUNC, original_minds_version: str | None = None, original_branch: str | None = None, prebaked_lima_image_qcow2_path: Path | None = None, @@ -594,7 +607,10 @@ def _build_mngr_create_command( id anyway, and pre-generating one led to bugs (e.g. keying gateway state under a fictional id). - DOCKER mode: --template main --template docker (runs in Docker container) + DOCKER mode: --template main --template docker (runs in a Docker container); + for ``docker_runtime == RUNSC`` the gVisor overlay is stacked on top + (--template docker_runsc) so the container runs under runsc. RUNC is the + docker template's default, so it adds no extra template. LIMA mode: --template main --template lima (runs in Lima VM) VULTR mode: --template main --template vultr (runs in Docker on a Vultr VPS) AWS mode: --new-host on the aws- provider, --template main @@ -743,6 +759,11 @@ def _build_mngr_create_command( match launch_mode: case LaunchMode.DOCKER: mngr_command.extend(["--new-host", "--template", "main", "--template", "docker"]) + if docker_runtime is DockerRuntime.RUNSC: + # gVisor overlay: reuses the docker template body and only flips + # the container runtime to runsc. runc is the docker template's + # default, so RUNC needs no extra template. + mngr_command.extend(["--template", "docker_runsc"]) mngr_command.extend(_remote_host_env_flags()) case LaunchMode.LIMA: mngr_command.extend(["--new-host", "--template", "main", "--template", "lima"]) @@ -971,6 +992,7 @@ def run_mngr_create( anthropic_base_url: str | None = None, latchkey_env: Mapping[str, str] | None = None, color: str | None = None, + docker_runtime: DockerRuntime = DockerRuntime.RUNC, original_minds_version: str | None = None, original_branch: str | None = None, prebaked_lima_image_qcow2_path: Path | None = None, @@ -1011,6 +1033,7 @@ def run_mngr_create( region=region, latchkey_env=latchkey_env, color=color, + docker_runtime=docker_runtime, original_minds_version=original_minds_version, original_branch=original_branch, prebaked_lima_image_qcow2_path=prebaked_lima_image_qcow2_path, @@ -1148,6 +1171,7 @@ class _MngrCreateAttemptParams(FrozenModel): anthropic_base_url: str | None parent_cg: ConcurrencyGroup | None color: str | None + docker_runtime: DockerRuntime original_minds_version: str | None original_branch: str | None # Resolved ready pre-baked Lima qcow2 path (issue 2306), or None to build in-VM. @@ -1186,6 +1210,7 @@ def _attempt_mngr_create(fast_mode: str | None, params: _MngrCreateAttemptParams anthropic_api_key=params.anthropic_api_key, anthropic_base_url=params.anthropic_base_url, color=params.color, + docker_runtime=params.docker_runtime, original_minds_version=params.original_minds_version, original_branch=params.original_branch, prebaked_lima_image_qcow2_path=params.prebaked_lima_image_qcow2_path, @@ -1382,6 +1407,7 @@ def start_creation( on_created: Callable[[AgentId], None] | None = None, backup_request: BackupSetupRequest | None = None, color: str | None = None, + docker_runtime: DockerRuntime = DockerRuntime.RUNC, original_minds_version: str = "", ) -> CreationId: """Start creating an agent from a git URL or local path in a background thread. @@ -1398,6 +1424,10 @@ def start_creation( - ``SUBSCRIPTION`` -- inject neither; the user signs in to Claude interactively in the workspace. + ``docker_runtime`` selects the container runtime for + ``LaunchMode.DOCKER`` (runc vs gVisor's runsc); it is ignored by every + other launch mode, which pin their own runtime. + For ``LaunchMode.IMBUE_CLOUD``, the agent runs on a leased pool host via the ``imbue_cloud_`` provider; the plugin's ``ImbueCloudProvider.create_host`` runs the lease + SSH bootstrap @@ -1459,6 +1489,7 @@ def start_creation( on_created, backup_request, color, + docker_runtime, original_minds_version, ), daemon=True, @@ -1522,6 +1553,7 @@ def _create_agent_background( on_created: Callable[[AgentId], None] | None = None, backup_request: BackupSetupRequest | None = None, color: str | None = None, + docker_runtime: DockerRuntime = DockerRuntime.RUNC, original_minds_version: str = "", ) -> None: """Background thread that resolves the repo source and creates an mngr agent. @@ -1772,6 +1804,7 @@ def _create_agent_background( anthropic_base_url=effective_anthropic_base_url, parent_cg=self.root_concurrency_group, color=color, + docker_runtime=docker_runtime, original_minds_version=original_minds_version or None, original_branch=branch or None, prebaked_lima_image_qcow2_path=prebaked_lima_image_qcow2_path, @@ -2005,7 +2038,7 @@ def _build_redirect_url(self, agent_id: AgentId) -> str: """ if self.mngr_forward_port == 0: return f"/goto/{agent_id}/" - return f"http://localhost:{self.mngr_forward_port}/goto/{agent_id}/" + return f"{_MNGR_FORWARD_SCHEME}://localhost:{self.mngr_forward_port}/goto/{agent_id}/" def _wait_for_workspace_ready(self, agent_id: AgentId, log_queue: queue.Queue[str]) -> None: """Poll the agent's system_interface through the plugin until it responds 200. diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/agent_creator_test.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/agent_creator_test.py index 9df786b5..75815a9a 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/agent_creator_test.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/agent_creator_test.py @@ -53,6 +53,7 @@ from imbue.minds.primitives import AIProvider from imbue.minds.primitives import BackupProvider from imbue.minds.primitives import CreationId +from imbue.minds.primitives import DockerRuntime from imbue.minds.primitives import GitBranch from imbue.minds.primitives import GitUrl from imbue.minds.primitives import LaunchMode @@ -60,6 +61,8 @@ from imbue.mngr.primitives import AgentId from imbue.mngr.primitives import HostName from imbue.mngr.utils.git_utils import GIT_MIRROR_PUSH_REFSPECS +from imbue.mngr_forward.tls import build_server_ssl_context +from imbue.mngr_forward.tls import generate_self_signed_cert from imbue.mngr_latchkey.agent_setup import SECRET_LATCHKEY_ENV_VAR_NAMES @@ -492,6 +495,51 @@ def test_build_mngr_create_command_ignores_region_for_docker() -> None: assert "region=" not in joined and "vultr-region" not in joined +def test_build_mngr_create_command_docker_runsc_stacks_gvisor_overlay() -> None: + """``DockerRuntime.RUNSC`` stacks the ``docker_runsc`` overlay on the docker template. + + The overlay reuses the docker template body and only flips the provider's + container runtime to runsc, so the host runs under gVisor. + """ + command = _build_mngr_create_command( + launch_mode=LaunchMode.DOCKER, + host_name=HostName("hello"), + docker_runtime=DockerRuntime.RUNSC, + ) + # The base docker template is always present; the runsc overlay is stacked + # immediately after it. + assert command.count("--template") >= 3 + docker_idx = command.index("docker") + assert command[docker_idx - 1] == "--template" + runsc_idx = command.index("docker_runsc") + assert command[runsc_idx - 1] == "--template" + # Order matters: the overlay must come AFTER the base so its provider + # setting wins the stack. + assert runsc_idx > docker_idx + + +def test_build_mngr_create_command_docker_runc_omits_gvisor_overlay() -> None: + """``DockerRuntime.RUNC`` (the docker template's default) adds no extra template.""" + command = _build_mngr_create_command( + launch_mode=LaunchMode.DOCKER, + host_name=HostName("hello"), + docker_runtime=DockerRuntime.RUNC, + ) + assert "docker" in command + assert "docker_runsc" not in command + + +@pytest.mark.parametrize("docker_runtime", [DockerRuntime.RUNC, DockerRuntime.RUNSC]) +def test_build_mngr_create_command_runtime_ignored_for_non_docker(docker_runtime: DockerRuntime) -> None: + """The runsc overlay is docker-only -- other launch modes never receive it.""" + command = _build_mngr_create_command( + launch_mode=LaunchMode.LIMA, + host_name=HostName("hello"), + docker_runtime=docker_runtime, + ) + assert "docker_runsc" not in command + + def test_build_mngr_create_command_omits_latchkey_when_env_is_empty() -> None: """Empty / ``None`` ``latchkey_env`` opts the host out of latchkey wiring entirely.""" for latchkey_env in (None, {}): @@ -938,6 +986,13 @@ def _start_scripted_server(not_ready_count: int) -> tuple[HTTPServer, threading. {"not_ready_count": not_ready_count, "request_count": 0, "lock": threading.Lock()}, ) server = HTTPServer(("127.0.0.1", 0), handler_cls) + # The readiness probe dials the proxy over https (minds always runs it with + # HTTP/2), so the stand-in server must speak TLS to match -- otherwise the + # probe's TLS handshake fails against a plain-HTTP socket. Reuse the proxy's + # own self-signed cert helpers so the test exercises the real https path. + cert_pem, key_pem = generate_self_signed_cert() + ssl_context = build_server_ssl_context(cert_pem, key_pem) + server.socket = ssl_context.wrap_socket(server.socket, server_side=True) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() port = server.server_address[1] @@ -1121,6 +1176,40 @@ def _capture(request: httpx.Request) -> httpx.Response: assert status == 503 +def test_probe_workspace_uses_https_scheme() -> None: + """The loopback probe dials https, matching the TLS + HTTP/2 proxy. + + The probe must hit the same transport the proxy speaks; a mismatch would + make every readiness probe fail the TLS handshake (or hit a closed http + port). + """ + captured: list[httpx.Request] = [] + + def _capture(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, text="ok") + + with httpx.Client(transport=httpx.MockTransport(_capture)) as client: + probe_workspace_through_plugin( + mngr_forward_port=18999, + preauth_cookie="any-preauth", + agent_id=AgentId.generate(), + probe_timeout_seconds=0.5, + client=client, + ) + + assert captured[0].url.scheme == "https" + assert captured[0].url.host == "127.0.0.1" + + +def test_build_redirect_url_uses_https_scheme(tmp_path) -> None: + """The /goto redirect URL the UI navigates to uses the proxy's https scheme.""" + creator = _make_test_creator(tmp_path, mngr_forward_port=8421) + aid = AgentId.generate() + url = creator._build_redirect_url(aid) + assert url == f"https://localhost:8421/goto/{aid}/" + + def test_wait_for_workspace_ready_publishes_anyway_on_timeout(tmp_path) -> None: """If the probe times out, we still return so the caller can publish the redirect.""" server, _thread, port = _start_scripted_server(not_ready_count=10**6) diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/api_models.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/api_models.py index 5adcd547..773bd67f 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/api_models.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/api_models.py @@ -21,6 +21,7 @@ from imbue.imbue_common.frozen_model import FrozenModel from imbue.minds.primitives import AIProvider from imbue.minds.primitives import BackupProvider +from imbue.minds.primitives import DockerRuntime from imbue.minds.primitives import LaunchMode @@ -192,6 +193,11 @@ class CreateWorkspaceRequest(ApiRequestModel): ai_provider: AIProvider | None = Field( default=None, description="How to obtain Anthropic credentials (default SUBSCRIPTION)" ) + runtime: DockerRuntime | None = Field( + default=None, + description="Docker container runtime for DOCKER launch mode (runc vs gVisor's runsc); " + "defaults to the platform-appropriate value (runc on macOS, runsc on Linux)", + ) account_id: str | None = Field(default=None, description="imbue_cloud account id (required for imbue_cloud modes)") anthropic_api_key: str | None = Field( default=None, description="Anthropic API key (required when ai_provider is API_KEY)" diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/api_v1.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/api_v1.py index 23484048..e87061d3 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/api_v1.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/api_v1.py @@ -147,8 +147,10 @@ from imbue.minds.primitives import AIProvider from imbue.minds.primitives import BackupProvider from imbue.minds.primitives import CreationId +from imbue.minds.primitives import DockerRuntime from imbue.minds.primitives import LaunchMode from imbue.minds.primitives import ServiceName +from imbue.minds.primitives import default_docker_runtime from imbue.minds.utils.mngr_caller import get_default_mngr_caller from imbue.mngr.primitives import AgentId from imbue.mngr.primitives import HostId @@ -553,6 +555,13 @@ def _handle_create_workspace() -> tuple[OperationHandleResponse, int] | Response ai_provider = AIProvider(str(body.get("ai_provider", AIProvider.SUBSCRIPTION.value))) except ValueError: return _json_error(f"Invalid ai_provider: {body.get('ai_provider')!r}", 400) + # Docker container runtime (runc vs gVisor's runsc); only consumed for + # LaunchMode.DOCKER. Defaults to the platform-appropriate value so macOS + # (no gVisor) gets runc and Linux gets the hardened runsc. + try: + docker_runtime = DockerRuntime(str(body.get("runtime", default_docker_runtime().value))) + except ValueError: + return _json_error(f"Invalid runtime: {body.get('runtime')!r}", 400) try: backup_provider = BackupProvider(str(body.get("backup_provider", BackupProvider.CONFIGURE_LATER.value))) except ValueError: @@ -657,6 +666,7 @@ def _handle_create_workspace() -> tuple[OperationHandleResponse, int] | Response on_created=on_created, backup_request=backup_request, color=color, + docker_runtime=docker_runtime, original_minds_version=(branch_or_tag or branch or FALLBACK_BRANCH), ) return OperationHandleResponse(operation_id=str(creation_id), kind="create"), 202 diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/api_v1_test.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/api_v1_test.py index f5c02f17..1b944baa 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/api_v1_test.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/api_v1_test.py @@ -51,6 +51,7 @@ from imbue.minds.desktop_client.workspace_operations import WorkspaceOperationStatus from imbue.minds.primitives import AIProvider from imbue.minds.primitives import CreationId +from imbue.minds.primitives import DockerRuntime from imbue.minds.primitives import LaunchMode from imbue.minds.testing import stub_mngr_host_dir from imbue.minds.utils.testing import RecordingMngrCaller @@ -112,6 +113,7 @@ def start_creation( on_created: Callable[[AgentId], None] | None = None, backup_request: BackupSetupRequest | None = None, color: str | None = None, + docker_runtime: DockerRuntime = DockerRuntime.RUNC, original_minds_version: str = "", ) -> CreationId: self._last_call = { @@ -126,6 +128,7 @@ def start_creation( "region": region, "anthropic_api_key": anthropic_api_key, "color": color, + "docker_runtime": docker_runtime, "original_minds_version": original_minds_version, } return CreationId() @@ -539,6 +542,7 @@ def test_create_workspace_full_surface_returns_202_and_threads_fields( "launch_mode": "DOCKER", "ai_provider": "SUBSCRIPTION", "backup_provider": "CONFIGURE_LATER", + "runtime": "RUNSC", }, ) @@ -549,6 +553,7 @@ def test_create_workspace_full_surface_returns_202_and_threads_fields( assert str(creator.last_call["host_name"]) == "my-mind" assert str(creator.last_call["color"]) == "#0b292b" assert str(creator.last_call["branch"]) == "main" + assert creator.last_call["docker_runtime"] == DockerRuntime.RUNSC def test_create_workspace_requires_api_key_for_api_key_provider( diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/app.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/app.py index 20968def..6aa8fd29 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/app.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/app.py @@ -208,10 +208,13 @@ def _get_mngr_forward_origin() -> str: """Build the bare-origin URL of the ``mngr forward`` plugin. Used by templates to construct ``/goto//`` URLs that target the - plugin (which owns subdomain forwarding) rather than minds. + plugin (which owns subdomain forwarding) rather than minds. minds always + runs the proxy with TLS + HTTP/2, so the scheme is ``https`` and the + rendered links reach it rather than failing a plaintext request against + the TLS listener. """ port = get_state().mngr_forward_port or 8421 - return f"http://localhost:{port}" + return f"https://localhost:{port}" def _get_is_mac() -> bool: @@ -2435,9 +2438,9 @@ def create_desktop_client( The agent-subdomain forwarding lives in the ``mngr_forward`` plugin (``libs/mngr_forward``) now; this app only serves minds-specific routes on the bare origin (login, landing, accounts, workspace settings, - sharing, agent create / destroy). Workspace links go to - ``http://localhost:/goto//`` instead of being - routed in-process. + sharing, agent create / destroy). Workspace links go to the proxy's + ``localhost:/goto//`` route (``https`` when the + proxy serves HTTP/2, else ``http``) instead of being routed in-process. ``envelope_stream_consumer`` feeds discovery events into ``backend_resolver`` and is also the bounce target for ``SIGHUP``-style diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/discovery_health.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/discovery_health.py index 02ef5d9f..fb5c76bf 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/discovery_health.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/discovery_health.py @@ -2,7 +2,7 @@ The discovery pipeline is two processes: a *producer* (``mngr observe --discovery-only``, a grandchild of the detached ``mngr latchkey forward`` -supervisor, which writes the shared discovery-events file every ~10s) and a +supervisor, which writes the shared discovery-events file on each poll cycle) and a *consumer* (the single ``mngr forward --observe-via-file`` subprocess minds spawns, which tails that file and folds snapshots into ``MngrCliBackendResolver``). When snapshots stop arriving the resolver freezes @@ -77,14 +77,21 @@ from imbue.mngr_latchkey.forward_supervisor import LatchkeyForwardSupervisor # How stale the resolver's ``last_event_at`` must be before the watchdog treats -# the producer as stalled. Discovery polls every ~10s (and every full snapshot -# bumps ``last_event_at``), so this is ~3-4 missed polls -- above the recovery -# redirect's own 30s freshness threshold so the two never fight over a single -# slow-but-healthy poll. -_DEFAULT_STALL_THRESHOLD_SECONDS: Final[float] = 35.0 +# the producer as stalled. This must clear the *slowest healthy* interval between +# discovery snapshots so that only a producer emitting literally nothing trips it +# (a merely-slow-but-alive producer must read as healthy -- restarting it is +# expensive, re-provisioning every managed host). mngr's per-provider discovery +# waits ``discovery_poll_interval_seconds`` (default 30s) *between* polls, and a +# single slow or erroring provider can take up to ``discovery_error_timeout_seconds`` +# (default 120s) to finish a poll while *still* emitting a snapshot at the end -- +# so an alive-but-slow single-provider setup can legitimately go ~150s between +# snapshots (30s wait + 120s poll). The threshold sits comfortably above that +# worst case (and well above the recovery redirect's own 30s freshness threshold), +# so genuine slowness never triggers a spurious bounce/restart storm. +_DEFAULT_STALL_THRESHOLD_SECONDS: Final[float] = 180.0 # Base wait before the first ``restart`` (and the doubling base for the backoff -# on subsequent restarts). ~one discovery poll cycle. +# on subsequent restarts). ~half a discovery poll interval. _DEFAULT_REMEDIATION_WAIT_SECONDS: Final[float] = 15.0 # Ceiling for the exponential backoff between restarts. With the 15s base the diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/e2e_workspace_runner.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/e2e_workspace_runner.py index a5f5947b..3be5cbc6 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/e2e_workspace_runner.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/e2e_workspace_runner.py @@ -69,8 +69,11 @@ _INBOX_PATH_PATTERN: Final[re.Pattern[str]] = re.compile(r"^http://localhost:\d+/inbox(?:/|$|\?)") # The agent subdomain URL the create flow redirects to once the workspace's # ``system_interface`` is reachable. The desktop client wraps that origin in -# the mngr_forward plugin, so the port may differ from the bare backend. -_AGENT_SUBDOMAIN_PATTERN: Final[re.Pattern[str]] = re.compile(r"^http://agent-[a-f0-9]+\.localhost:\d+(?:/|$)") +# the mngr_forward plugin, so the port may differ from the bare backend. The +# scheme is ``https`` when the proxy serves TLS + HTTP/2 (the default) and +# ``http`` otherwise, so accept both. (The bare minds backend origin stays +# plain ``http`` -- see ``_BACKEND_ORIGIN_PATTERN``.) +_AGENT_SUBDOMAIN_PATTERN: Final[re.Pattern[str]] = re.compile(r"^https?://agent-[a-f0-9]+\.localhost:\d+(?:/|$)") # Default env tier when nothing is activated. Staging's ``client.toml`` is # committed under apps/minds/imbue/minds/config/envs/staging/ so callers diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/e2e_workspace_runner_test.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/e2e_workspace_runner_test.py index dbd960e6..d1042cd3 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/e2e_workspace_runner_test.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/e2e_workspace_runner_test.py @@ -70,6 +70,17 @@ def test_wait_returns_when_workspace_url_reached() -> None: _wait_for_workspace_ready_or_failure(cast(Page, page), timeout_seconds=5) +def test_wait_returns_for_https_workspace_url() -> None: + """The workspace origin is https when the proxy serves TLS + HTTP/2 (the default). + + The ready-check must recognize that scheme, not just http -- otherwise the + waiter never sees the workspace as ready and times out even though it loaded. + """ + https_ready_url = "https://agent-deadbeef.localhost:8421/" + page = _FakePage(urls=[https_ready_url], is_visible_results=[False]) + _wait_for_workspace_ready_or_failure(cast(Page, page), timeout_seconds=5) + + def test_wait_raises_with_surfaced_error_on_failure_view() -> None: page = _FakePage( urls=[_PENDING_URL], diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/forward_cli.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/forward_cli.py index 363567cd..73dcd600 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/forward_cli.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/forward_cli.py @@ -622,6 +622,28 @@ def start_mngr_forward( dropped. """ preauth_cookie = secrets.token_urlsafe(_PREAUTH_TOKEN_LENGTH) + command = _build_forward_command(config, preauth_cookie) + env = dict(os.environ) + env["MNGR_HOST_DIR"] = str(config.mngr_host_dir) + logger.info("Spawning `mngr forward` subprocess: {}", " ".join(_redact_secrets(command))) + # noqa: S603 — command is fully controlled (mngr binary + the args we + # build above), no untrusted input reaches the argv list. + process = subprocess.Popen( # noqa: S603 + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + env=env, + cwd=str(Path.home()), + ) + consumer = EnvelopeStreamConsumer(resolver=resolver) + consumer.attach(process) + return consumer, preauth_cookie + + +def _build_forward_command(config: ForwardSubprocessConfig, preauth_cookie: str) -> list[str]: + """Build the ``mngr forward`` argv for the subprocess minds spawns.""" command: list[str] = [ config.mngr_binary, "forward", @@ -637,27 +659,15 @@ def start_mngr_forward( "--format", "jsonl", ] + # TLS + HTTP/2 so the workspace origin is not capped by Chromium's + # per-origin HTTP/1.1 connection limit. The Electron shell trusts the + # proxy's self-signed cert for its loopback origins. + command.append("--use-http2") for include in config.agent_include: command.extend(["--agent-include", include]) for spec in config.reverse_specs: command.extend(["--reverse", spec]) - env = dict(os.environ) - env["MNGR_HOST_DIR"] = str(config.mngr_host_dir) - logger.info("Spawning `mngr forward` subprocess: {}", " ".join(_redact_secrets(command))) - # noqa: S603 — command is fully controlled (mngr binary + the args we - # build above), no untrusted input reaches the argv list. - process = subprocess.Popen( # noqa: S603 - command, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - env=env, - cwd=str(Path.home()), - ) - consumer = EnvelopeStreamConsumer(resolver=resolver) - consumer.attach(process) - return consumer, preauth_cookie + return command def _redact_secrets(command: list[str]) -> list[str]: diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/forward_cli_test.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/forward_cli_test.py index ce6466c1..18252328 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/forward_cli_test.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/forward_cli_test.py @@ -25,6 +25,8 @@ from imbue.imbue_common.event_envelope import IsoTimestamp from imbue.minds.desktop_client.backend_resolver import MngrCliBackendResolver from imbue.minds.desktop_client.forward_cli import EnvelopeStreamConsumer +from imbue.minds.desktop_client.forward_cli import ForwardSubprocessConfig +from imbue.minds.desktop_client.forward_cli import _build_forward_command from imbue.minds.desktop_client.forward_cli import _redact_secrets from imbue.minds.primitives import ServiceName from imbue.mngr.api.discovery_events import AgentDestroyedEvent @@ -679,6 +681,38 @@ def test_start_before_attach_raises(consumer: EnvelopeStreamConsumer) -> None: consumer.start(cg) +# --- _build_forward_command ---------------------------------------------- + + +def test_build_forward_command_includes_use_http2_flag() -> None: + """The spawned argv always carries --use-http2 so the proxy serves TLS. + + minds always runs the proxy with TLS + HTTP/2, matching the https/wss URLs + the rest of minds builds, so a client that expects https reaches an https + proxy. + """ + config = ForwardSubprocessConfig(service="system_interface") + command = _build_forward_command(config, preauth_cookie="a-secret") + assert "--use-http2" in command + # Core flags are always present alongside the TLS flag. + assert command[:2] == [config.mngr_binary, "forward"] + assert "--observe-via-file" in command + assert command[command.index("--service") + 1] == "system_interface" + assert command[command.index("--preauth-cookie") + 1] == "a-secret" + + +def test_build_forward_command_threads_includes_and_reverse_specs() -> None: + """Agent-include and reverse specs are expanded into repeated flags.""" + config = ForwardSubprocessConfig( + agent_include=("has(agent.labels.is_primary)", "agent.name == 'x'"), + reverse_specs=("8420:8420",), + ) + command = _build_forward_command(config, preauth_cookie="s") + includes = [command[i + 1] for i, tok in enumerate(command) if tok == "--agent-include"] + assert includes == ["has(agent.labels.is_primary)", "agent.name == 'x'"] + assert command[command.index("--reverse") + 1] == "8420:8420" + + # --- _redact_secrets ------------------------------------------------------ diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/recovery_probe.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/recovery_probe.py index 84a65376..1db7f804 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/recovery_probe.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/recovery_probe.py @@ -141,7 +141,10 @@ class DispatchTier(str, Enum): Either the in-container probe timed out (it observed nothing -- absence of evidence, not evidence the workspace is down), or the discovery snapshot backing the host state predates the outage onset (a pre-outage snapshot still - reads the stale host state, e.g. a just-stopped container still shows RUNNING). + reads the stale host state, e.g. a just-stopped container still shows RUNNING), + or the snapshot itself carries no observation of the container (host state + UNKNOWN -- the host was unobservable during discovery -- or a + transitional/absent state). A negative verdict or an auto-dispatched restart off such non-evidence is exactly the misclassification this tier avoids. The recovery page renders a live "reconnecting" state and keeps checking: the cheap liveness poll returns @@ -159,10 +162,17 @@ class DispatchTier(str, Enum): """Container is offline -- restart the host (no live work to interrupt).""" HOST_UNRESPONSIVE = "host_unresponsive" - """Container claims running but we can't reach it -- require explicit user consent. - - Also the fallback for any ambiguous host state: the host is not responding - in the way we expect, so we ask the user before bouncing it. + """Container was observed running but we cannot get inside it. + + Covers an observed RUNNING claim whose exec cleanly failed, and the + UNAUTHENTICATED state -- which providers report when the container was + observed running but inner SSH is unreachable (e.g. its sshd died; see + PR #2247). A host restart bounces a possibly-live container, so it + requires explicit user consent -- and for the dead-inner-sshd case that + consent-gated restart is the engineered recovery (the stop step is not + skipped, so the relaunch brings sshd back). A host state that answers + neither "running" nor "offline" is non-evidence and classifies + INDETERMINATE instead. """ BACKEND_UNREACHABLE = "backend_unreachable" @@ -193,7 +203,7 @@ class HostHealthResponse(FrozenModel): default=(), description="Ordered probe results to render in the diagnostics list." ) dispatch_tier: DispatchTier = Field( - default=DispatchTier.HOST_UNRESPONSIVE, + default=DispatchTier.INDETERMINATE, description="Restart-tier classification derived from probe answers.", ) unreachable_reason: str = Field( @@ -305,6 +315,15 @@ def _coerce_optional_int(value: object) -> int | None: _RUNNING_STATE: Final[str] = "RUNNING" +# UNAUTHENTICATED is, by provider convention (docker's connection-error fallback +# hook and imbue_cloud's listing path; see PR #2247), "the container was observed +# running but inner SSH is unreachable" -- so for the "is the container running?" +# question it is an observed YES. This routes the dead-inner-sshd case to the +# consent-gated HOST_UNRESPONSIVE restart that actually fixes it. The imbue_cloud +# outer-SSH-auth-rejected fallback reuses the same state, where a restart fails on +# the same auth error; distinguishing the two needs a dedicated provider state +# (e.g. UNREACHABLE), which remains deferred (flagged in PR #2247). +_OBSERVED_RUNNING_STATES: Final[frozenset[str]] = frozenset({_RUNNING_STATE, "UNAUTHENTICATED"}) _OFFLINE_HOST_STATES: Final[frozenset[str]] = frozenset({"STOPPED", "STOPPING", "CRASHED", "FAILED"}) @@ -343,7 +362,7 @@ def _mngr_exec_command(mngr_binary: str, services_agent_id: AgentId | None, inne def _build_container_running_probe(host_state: str) -> Probe: """Probe 1: the workspace host's lifecycle state, read from the discovery snapshot.""" upper = host_state.upper() - if upper == _RUNNING_STATE: + if upper in _OBSERVED_RUNNING_STATES: answer = ProbeAnswer.YES elif upper in _OFFLINE_HOST_STATES: answer = ProbeAnswer.NO @@ -591,17 +610,20 @@ def _classify_dispatch_tier( host-state-derived tier below. * INDETERMINATE when we have no direct in-container evidence AND cannot trust a negative verdict: the probe timed out (it observed *nothing* -- a timeout is - absence of evidence, not evidence of a down workspace), or no discovery + absence of evidence, not evidence of a down workspace), no discovery snapshot taken at/after the outage onset backs the host state (a pre-outage - snapshot still reads the stale host state). A verdict or auto-restart off - such non-evidence is the misclassification we avoid; the recovery page keeps - checking instead. - * HOST_OFFLINE when the container is offline (and we trust that observation): - nothing live to interrupt, so a host restart can run unattended. - * HOST_UNRESPONSIVE when the container claims running but the exec cleanly - failed to reach it (ssh dead), or on any other ambiguous but trusted host - state: a host restart bounces a live container, so it requires explicit user - consent. + snapshot still reads the stale host state), or the snapshot itself carries + no observation of the container (host state UNKNOWN -- e.g. an imbue_cloud + host whose outer SSH was unreachable, which says the *path* to the host is + broken, not that the container is down -- or a transitional/absent state). + A verdict or restart affordance off such non-evidence is the + misclassification we avoid; the recovery page keeps checking instead. + * HOST_OFFLINE when the container was observed offline (and we trust that + observation): nothing live to interrupt, so a host restart can run + unattended. + * HOST_UNRESPONSIVE when the container was observed running but the exec + cleanly failed to reach it (ssh dead): a host restart bounces a live + container, so it requires explicit user consent. """ if provider_error_message is not None: return DispatchTier.BACKEND_UNREACHABLE @@ -629,7 +651,12 @@ def _classify_dispatch_tier( container_running = answers.get(_QUESTION_CONTAINER_RUNNING) if container_running == ProbeAnswer.NO: return DispatchTier.HOST_OFFLINE - return DispatchTier.HOST_UNRESPONSIVE + if container_running == ProbeAnswer.YES: + return DispatchTier.HOST_UNRESPONSIVE + # The snapshot carries no observation of the container (host state UNKNOWN, + # transitional, or absent). That is non-evidence, same as a timed-out probe: + # render no verdict and offer no restart -- keep checking. + return DispatchTier.INDETERMINATE def build_host_health_response( diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/recovery_probe_test.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/recovery_probe_test.py index a5436c5f..8844c95a 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/recovery_probe_test.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/recovery_probe_test.py @@ -123,6 +123,17 @@ def test_container_running_probe_says_no_when_host_state_is_stopped() -> None: assert _answer(response, "container running") == ProbeAnswer.NO +def test_container_running_probe_says_yes_when_host_state_is_unauthenticated() -> None: + """UNAUTHENTICATED means the container was observed running but inner SSH is dead. + + Both producers (docker's connection-error fallback hook and imbue_cloud's + listing path; PR #2247) emit it only after observing a running container, so + the "is the container running?" answer is an observed YES. + """ + response = _response(host_state="UNAUTHENTICATED") + assert _answer(response, "container running") == ProbeAnswer.YES + + def test_container_running_probe_is_unknown_for_ambiguous_host_state() -> None: response = _response(host_state="STARTING") assert _answer(response, "container running") == ProbeAnswer.UNKNOWN @@ -297,11 +308,43 @@ def test_dispatch_tier_host_unresponsive_when_container_running_but_exec_dead() assert response.dispatch_tier == DispatchTier.HOST_UNRESPONSIVE -def test_dispatch_tier_host_unresponsive_for_ambiguous_host_state() -> None: - response = _response(host_state="STARTING", in_container_stdout=None) +def test_dispatch_tier_host_unresponsive_for_unauthenticated_host_state() -> None: + """Dead inner sshd (`pkill sshd` in the container) -> consent-gated host restart. + + The provider observed the container running but could not get inside, so it + reported UNAUTHENTICATED. The consent-gated host restart is the engineered + recovery for this state (PR #2247): its stop step is not skipped, so the + stop/start relaunches the inner sshd. This must NOT classify INDETERMINATE -- + a dead sshd never self-heals, so "keep checking" would strand the user. + """ + response = _response(host_state="UNAUTHENTICATED", in_container_stdout=None) assert response.dispatch_tier == DispatchTier.HOST_UNRESPONSIVE +def test_dispatch_tier_indeterminate_for_ambiguous_host_state() -> None: + """A transitional host state is not an observation of the container -> keep checking. + + Only an observed RUNNING claim earns the consent-gated HOST_UNRESPONSIVE + verdict; a state that answers neither "running" nor "offline" is non-evidence, + so no verdict (and no restart affordance) is rendered off it. + """ + response = _response(host_state="STARTING", in_container_stdout=None) + assert response.dispatch_tier == DispatchTier.INDETERMINATE + + +def test_dispatch_tier_indeterminate_for_unknown_host_state() -> None: + """An UNKNOWN host state (host unobservable during discovery) -> keep checking. + + The imbue_cloud provider surfaces UNKNOWN when a leased host's outer SSH is + unreachable: the *path* to the host is broken, which says nothing about the + container. Rendering a host-offline/unresponsive verdict (with its restart + affordance) off that would offer a restart that is doomed for exactly the + same reason the host is unreachable -- so it must classify INDETERMINATE. + """ + response = _response(host_state="UNKNOWN", in_container_stdout=None) + assert response.dispatch_tier == DispatchTier.INDETERMINATE + + def test_dispatch_tier_indeterminate_when_probe_timed_out() -> None: """A timed-out in-container probe observed nothing, so no verdict is rendered. diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/templates.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/templates.py index f2ae771f..8f6ce143 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/templates.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/templates.py @@ -34,8 +34,10 @@ from imbue.minds.primitives import AIProvider from imbue.minds.primitives import BackupProvider from imbue.minds.primitives import CreationId +from imbue.minds.primitives import DockerRuntime from imbue.minds.primitives import LaunchMode from imbue.minds.primitives import OneTimeCode +from imbue.minds.primitives import default_docker_runtime from imbue.minds.utils.sentry.frontend import frontend_sentry_browser_payload from imbue.mngr.primitives import AgentId from imbue.mngr.primitives import HostName @@ -414,6 +416,7 @@ def render_create_form( host_name: str = "", launch_mode: LaunchMode | None = None, ai_provider: AIProvider | None = None, + docker_runtime: DockerRuntime | None = None, backup_provider: BackupProvider | None = None, backup_api_key_env: str = "", has_saved_backup_password: bool = False, @@ -488,6 +491,10 @@ def render_create_form( if ai_provider is not None else (AIProvider.IMBUE_CLOUD if is_remote_preset else AIProvider.SUBSCRIPTION) ) + # The Docker container-runtime select defaults to the platform-appropriate + # value (runc on macOS, runsc on Linux). Only consumed when the compute + # provider is Docker; the form hides it otherwise. + effective_docker_runtime = docker_runtime if docker_runtime is not None else default_docker_runtime() effective_backup_provider = ( backup_provider if backup_provider is not None @@ -502,6 +509,8 @@ def render_create_form( selected_launch_mode=effective_launch_mode.value, ai_providers=list(AIProvider), selected_ai_provider=effective_ai_provider.value, + docker_runtimes=list(DockerRuntime), + selected_docker_runtime=effective_docker_runtime.value, backup_providers=list(BackupProvider), selected_backup_provider=effective_backup_provider.value, backup_api_key_env=backup_api_key_env, @@ -1172,10 +1181,10 @@ def render_inbox_unavailable_fragment(message: str = "") -> str: if (providerReasonEl) { providerReasonEl.textContent = ''; show(providerReasonEl, false); } latestHealth = null; } - // The shared "Workspace unresponsive" state -- shown for ambiguous-host - // states, after a restart failure, and whenever the container is live - // but unreachable (bouncing it would interrupt user agents, so we want - // explicit consent before doing so). + // The shared "Workspace unresponsive" state -- shown after a restart + // failure and for the host_unresponsive tier (container observed + // running but unreachable: bouncing it would interrupt user agents, so + // we want explicit consent before doing so). function renderUnresponsive() { titleEl.textContent = 'Workspace unresponsive'; messageEl.textContent = @@ -1193,9 +1202,11 @@ def render_inbox_unavailable_fragment(message: str = "") -> str: armHealthyPoll(); } // INDETERMINATE: we lack trustworthy evidence to classify -- the - // in-container probe timed out (observed nothing), or discovery has not - // re-observed the host since the outage began, so its host state may be - // stale. Render neither a verdict nor a restart button, just a live + // in-container probe timed out (observed nothing), discovery has not + // re-observed the host since the outage began (so its host state may be + // stale), or the snapshot carries no observation of the container (host + // state UNKNOWN, transitional, or absent). + // Render neither a verdict nor a restart button, just a live // "reconnecting" spinner. The cheap liveness poll (armed here) returns the // user home the instant the workspace answers; a slow heavy re-probe // converges to a real tier if it is genuinely down and a fresh snapshot @@ -1315,8 +1326,9 @@ def render_inbox_unavailable_fragment(message: str = "") -> str: scheduleRefresh(); return; } - // No trustworthy evidence to classify (probe timed out, or discovery has - // not re-observed the host since the outage). Show a live "reconnecting" + // No trustworthy evidence to classify (probe timed out, discovery has + // not re-observed the host since the outage, or the snapshot carries no + // observation of the container). Show a live "reconnecting" // state and keep checking -- never a verdict or an auto-restart off // non-evidence -- on EITHER entry path. Checked before the restart_failed // branch below so an indeterminate result there also keeps checking diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/templates/pages/Create.jinja b/vendor/mngr/apps/minds/imbue/minds/desktop_client/templates/pages/Create.jinja index 4ec51bcb..4cfc9338 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/templates/pages/Create.jinja +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/templates/pages/Create.jinja @@ -1,4 +1,4 @@ -{#def git_url, branch, host_name, launch_modes, selected_launch_mode, ai_providers, selected_ai_provider, backup_providers, selected_backup_provider, backup_api_key_env, has_saved_backup_password, is_master_password_set, accounts, default_account_id, anthropic_api_key, error_message, region_options_by_launch_mode, region_selected_by_launch_mode, selected_preset="remote", start_advanced=False, color="#0b292b" #} +{#def git_url, branch, host_name, launch_modes, selected_launch_mode, ai_providers, selected_ai_provider, docker_runtimes, selected_docker_runtime, backup_providers, selected_backup_provider, backup_api_key_env, has_saved_backup_password, is_master_password_set, accounts, default_account_id, anthropic_api_key, error_message, region_options_by_launch_mode, region_selected_by_launch_mode, selected_preset="remote", start_advanced=False, color="#0b292b" #} {# Workspace creation form. @@ -62,6 +62,8 @@ var submitBtn = document.getElementById('create-submit'); var regionRow = document.getElementById('region-row'); var regionSelect = document.getElementById('region'); + var runtimeRow = document.getElementById('runtime-row'); + var runtimeSelect = document.getElementById('runtime'); var presetCards = document.querySelectorAll('[data-preset]'); var accountError = document.getElementById('account-error'); // Inputs read when building the create request body. @@ -341,7 +343,12 @@ backup_api_key_env: fieldValue(backupApiKeyEnvInput), backup_master_password: fieldValue(backupMasterPasswordInput), backup_save_password: backupSavePasswordInput ? backupSavePasswordInput.checked : false, - region: fieldValue(regionSelect) + region: fieldValue(regionSelect), + // The container runtime only applies to the local Docker provider; the + // select is disabled for every other compute provider, so omit it then + // (JSON.stringify drops undefined keys) and let the server pick its + // platform default. + runtime: runtimeSelect.disabled ? undefined : runtimeSelect.value }; fetch('/api/v1/workspaces', { method: 'POST', @@ -452,6 +459,15 @@ regionRow.classList.remove('hidden'); } + function updateRuntime() { + // The container-runtime select only applies to the local Docker provider; + // every other compute provider pins its own runtime. Hide + disable it + // otherwise so no runtime value is submitted. + var isDocker = launchSelect.value === 'DOCKER'; + runtimeRow.classList.toggle('hidden', !isDocker); + runtimeSelect.disabled = !isDocker; + } + function update() { var hasAccount = accountSelect.value !== ''; @@ -495,6 +511,7 @@ submitBtn.disabled = false; updateRegion(); + updateRuntime(); } accountSelect.addEventListener('change', onAccountChange); @@ -710,6 +727,17 @@ + + {# Dashed divider separating the provider / region selects above from the repository / branch text inputs below. #}
diff --git a/vendor/mngr/apps/minds/imbue/minds/desktop_client/templates_test.py b/vendor/mngr/apps/minds/imbue/minds/desktop_client/templates_test.py index 99d64c4b..0cdbeace 100644 --- a/vendor/mngr/apps/minds/imbue/minds/desktop_client/templates_test.py +++ b/vendor/mngr/apps/minds/imbue/minds/desktop_client/templates_test.py @@ -29,8 +29,10 @@ from imbue.minds.desktop_client.workspace_color import normalize_workspace_color from imbue.minds.desktop_client.workspace_color import pick_unused_create_color from imbue.minds.primitives import AIProvider +from imbue.minds.primitives import DockerRuntime from imbue.minds.primitives import LaunchMode from imbue.minds.primitives import OneTimeCode +from imbue.minds.primitives import default_docker_runtime from imbue.mngr.primitives import AgentId # The hand-written Tailwind v4 source. Holds the :root design tokens (the @@ -323,6 +325,27 @@ def test_render_create_form_contains_ai_provider_options() -> None: assert f'value="{provider.value}"' in html +def test_render_create_form_contains_docker_runtime_options() -> None: + html = render_create_form() + for runtime in DockerRuntime: + assert f'value="{runtime.value}"' in html + + +def test_render_create_form_defaults_docker_runtime_to_platform_value() -> None: + # The runtime select pre-selects the platform-appropriate default (runc on + # macOS, runsc on Linux) so the form works out of the box on either host. + html = render_create_form() + assert f'value="{default_docker_runtime().value}" selected' in html + + +def test_render_create_form_selects_specified_docker_runtime() -> None: + # Pick the runtime that is NOT this platform's default so the "selection + # honored over the default" assertion is meaningful on both macOS and Linux. + non_default = DockerRuntime.RUNSC if default_docker_runtime() is DockerRuntime.RUNC else DockerRuntime.RUNC + html = render_create_form(docker_runtime=non_default) + assert f'value="{non_default.value}" selected' in html + + def test_render_create_form_defaults_ai_provider_to_imbue_cloud() -> None: # The remote preset is the default, so the AI provider starts on IMBUE_CLOUD # rather than the local SUBSCRIPTION default. diff --git a/vendor/mngr/apps/minds/imbue/minds/primitives.py b/vendor/mngr/apps/minds/imbue/minds/primitives.py index ba99f3ce..2f343a2a 100644 --- a/vendor/mngr/apps/minds/imbue/minds/primitives.py +++ b/vendor/mngr/apps/minds/imbue/minds/primitives.py @@ -1,3 +1,5 @@ +import os +import platform from enum import auto from typing import Final @@ -74,6 +76,55 @@ class LaunchMode(UpperCaseStrEnum): MODAL = auto() +class DockerRuntime(UpperCaseStrEnum): + """Container runtime for the local Docker compute provider (``LaunchMode.DOCKER``). + + - ``RUNC`` -- Docker's default runtime. Works everywhere, including macOS. + - ``RUNSC`` -- gVisor, which intercepts the container's syscalls to shrink + the host kernel attack surface for untrusted agents. Requires ``runsc`` to + be installed and registered with the local Docker daemon (Linux in + practice); it is unavailable on macOS. + + Only meaningful for the Docker compute provider; the other launch modes pin + their own runtime. The create form defaults this to the platform-appropriate + value (see :func:`default_docker_runtime`) and lets the user override it + under advanced settings. + """ + + RUNC = auto() + RUNSC = auto() + + +# Env override for the create-form / create-API default runtime, consulted by +# ``default_docker_runtime``. Set it to a ``DockerRuntime`` value +# (case-insensitive) to force the default. CI and the e2e snapshot build set it +# to ``RUNC`` because their Docker daemon has no gVisor (runsc) registered; a +# real deployment leaves it unset, so Linux still defaults to the hardened +# runsc. This is the layer that decides whether the create stacks the +# ``docker_runsc`` template at all -- distinct from +# ``MNGR__PROVIDERS__DOCKER__DOCKER_RUNTIME``, which only overrides the mngr +# provider config and cannot override a template that was explicitly stacked. +_DEFAULT_DOCKER_RUNTIME_ENV_VAR: Final[str] = "MINDS_DOCKER_RUNTIME_DEFAULT" + + +def default_docker_runtime() -> DockerRuntime: + """Return the default Docker container runtime for the create form / API. + + An explicit ``MINDS_DOCKER_RUNTIME_DEFAULT`` env override wins when set + (CI uses it to pin runc, having no gVisor). Otherwise the platform default: + macOS has no gVisor so it must use runc; Linux defaults to the + gVisor-hardened runsc (which the minds app assumes is installed there). + + Raises ``ValueError`` if the override is set to a value that is not a + ``DockerRuntime`` -- a misconfigured knob should fail loud, not silently + fall back. + """ + override = os.environ.get(_DEFAULT_DOCKER_RUNTIME_ENV_VAR) + if override: + return DockerRuntime(override.strip().upper()) + return DockerRuntime.RUNC if platform.system() == "Darwin" else DockerRuntime.RUNSC + + class AIProvider(UpperCaseStrEnum): """How the workspace agent should obtain its Anthropic credentials. diff --git a/vendor/mngr/apps/minds/imbue/minds/primitives_test.py b/vendor/mngr/apps/minds/imbue/minds/primitives_test.py new file mode 100644 index 00000000..02d50aa7 --- /dev/null +++ b/vendor/mngr/apps/minds/imbue/minds/primitives_test.py @@ -0,0 +1,24 @@ +import pytest + +from imbue.minds.primitives import DockerRuntime +from imbue.minds.primitives import default_docker_runtime + + +@pytest.mark.parametrize("value", [DockerRuntime.RUNC, DockerRuntime.RUNSC]) +def test_default_docker_runtime_env_override_wins(value: DockerRuntime, monkeypatch: pytest.MonkeyPatch) -> None: + # The override lets CI (which has no gVisor) pin the create default to runc + # regardless of the platform, so minds never stacks the docker_runsc template. + monkeypatch.setenv("MINDS_DOCKER_RUNTIME_DEFAULT", value.value) + assert default_docker_runtime() is value + + +def test_default_docker_runtime_env_override_is_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MINDS_DOCKER_RUNTIME_DEFAULT", "runc") + assert default_docker_runtime() is DockerRuntime.RUNC + + +def test_default_docker_runtime_invalid_override_raises(monkeypatch: pytest.MonkeyPatch) -> None: + # A misconfigured knob must fail loud rather than silently fall back. + monkeypatch.setenv("MINDS_DOCKER_RUNTIME_DEFAULT", "gvisor") + with pytest.raises(ValueError): + default_docker_runtime() diff --git a/vendor/mngr/apps/minds/imbue/minds/test_ratchets.py b/vendor/mngr/apps/minds/imbue/minds/test_ratchets.py index 09dd345d..f137af75 100644 --- a/vendor/mngr/apps/minds/imbue/minds/test_ratchets.py +++ b/vendor/mngr/apps/minds/imbue/minds/test_ratchets.py @@ -365,7 +365,7 @@ def test_prevent_inline_functions() -> None: # ported Sentry HTTP transport's ``_send_request`` (it closes over the # envelope being sent). The recorded count reflects the actual finder count # for the current tree. - rc.check_inline_functions(_DIR, snapshot(9)) + rc.check_inline_functions(_DIR, snapshot(7)) def test_prevent_underscore_imports() -> None: diff --git a/vendor/mngr/apps/minds/imbue/minds/utils/sentry/core.py b/vendor/mngr/apps/minds/imbue/minds/utils/sentry/core.py index 83bef8dc..7f99afae 100644 --- a/vendor/mngr/apps/minds/imbue/minds/utils/sentry/core.py +++ b/vendor/mngr/apps/minds/imbue/minds/utils/sentry/core.py @@ -16,6 +16,7 @@ from imbue.minds.bootstrap import resolve_minds_root_name from imbue.minds.build_info import resolve_git_sha from imbue.minds.build_info import resolve_release_id +from imbue.mngr.utils.logging import SENTRY_IGNORED_STDLIB_LOGGER_PATTERNS from imbue.mngr_latchkey.sentry import ForwardSentryConsent from imbue.mngr_latchkey.sentry import MNGR_LATCHKEY_SENTRY_CONSENT_FILE_ENV_VAR from imbue.mngr_latchkey.sentry import MNGR_LATCHKEY_SENTRY_DSN_ENV_VAR @@ -73,12 +74,16 @@ class SentryDeployEnvironment(StrEnum): # Minds writes all of its logs flat into a single logs directory (``~/.minds/logs``): -# * ``minds-events.jsonl`` -- the live Python backend log (the loguru JSONL sink) -# * ``minds-events.jsonl.`` -- rotated Python backend logs (timestamp-suffixed by make_jsonl_file_sink) -# * ``minds.log`` -- the Electron main-process log -# None of these are gzip-compressed on disk, so every file is compressed on upload. +# * ``minds-events.jsonl`` -- the live Python backend log (the loguru JSONL sink) +# * ``minds-events.jsonl.`` -- rotated Python backend logs (timestamp-suffixed, uncompressed) +# * ``minds.log`` -- the backend subprocess's stdout/stderr, written by the Electron shell +# * ``minds.log..gz`` -- rotated (gzipped) backend logs, written by the Electron shell +# * ``electron.log`` -- the Electron main-process log +# * ``electron.log..gz`` -- rotated (gzipped) Electron main-process logs +# The live/current files are uncompressed on disk (compressed on upload); the rotated ``*.gz`` +# files are already gzipped by the Electron rotation helper, so they are uploaded as-is. _MINDS_LOG_ATTACHMENT_GROUPS = ( - # The live Python backend log (mutable -- re-upload on every report). + # The live Python backend jsonl log (mutable -- re-upload on every report). LogAttachmentGroup( group_name="live_logs", glob="*.jsonl", @@ -86,7 +91,7 @@ class SentryDeployEnvironment(StrEnum): is_compressed=True, is_immutable=False, ), - # Rotated Python backend logs (immutable -- upload once and reuse the cached key). + # Rotated Python backend jsonl logs (immutable -- upload once and reuse the cached key). LogAttachmentGroup( group_name="rotated_logs", glob="*.jsonl.*", @@ -94,14 +99,38 @@ class SentryDeployEnvironment(StrEnum): is_compressed=True, is_immutable=True, ), - # The Electron main-process log. + # The current backend stdout/stderr log (minds.log). + LogAttachmentGroup( + group_name="backend_logs", + glob="minds.log", + max_file_count=MAX_SENTRY_LIST_SIZE, + is_compressed=True, + is_immutable=False, + ), + # The most recent rotated backend log (already gzipped on disk, so not re-compressed). + LogAttachmentGroup( + group_name="backend_rotated_logs", + glob="minds.log.*.gz", + max_file_count=1, + is_compressed=False, + is_immutable=True, + ), + # The current Electron main-process log (electron.log). LogAttachmentGroup( group_name="electron_logs", - glob="*.log", + glob="electron.log", max_file_count=MAX_SENTRY_LIST_SIZE, is_compressed=True, is_immutable=False, ), + # The most recent rotated Electron log (already gzipped on disk, so not re-compressed). + LogAttachmentGroup( + group_name="electron_rotated_logs", + glob="electron.log.*.gz", + max_file_count=1, + is_compressed=False, + is_immutable=True, + ), ) @@ -210,4 +239,8 @@ def setup_sentry( is_error_reporting_enabled=is_error_reporting_enabled, is_log_inclusion_enabled=is_log_inclusion_enabled, s3_attachment_bucket=_s3_attachment_bucket_for_environment(environment), + # paramiko/pyinfra log handled SSH connection-failure noise at ERROR via stdlib logging; the + # minds backend brokers cross-workspace reverse tunnels through the same paramiko machinery, + # so ignore those loggers to keep Sentry from flooding on already-handled failures. + ignored_loggers=SENTRY_IGNORED_STDLIB_LOGGER_PATTERNS, ) diff --git a/vendor/mngr/apps/minds/imbue/minds/utils/sentry/core_test.py b/vendor/mngr/apps/minds/imbue/minds/utils/sentry/core_test.py index 45cdd7d3..2ec1e1f8 100644 --- a/vendor/mngr/apps/minds/imbue/minds/utils/sentry/core_test.py +++ b/vendor/mngr/apps/minds/imbue/minds/utils/sentry/core_test.py @@ -113,15 +113,21 @@ def test_consent_file_round_trips_minds_settings_to_the_daemon_reader(tmp_path: def test_collect_external_attachments_classifies_flat_minds_log_layout(tmp_path: Path) -> None: - # The minds logs dir is flat: a live `*.jsonl`, timestamp-suffixed rotated - # `*.jsonl.` logs, and the Electron `*.log`. Each must land in its own - # group, and the globs must not cross-match (e.g. `*.jsonl` must not pick up - # the rotated files). + # The minds logs dir is flat: the live backend jsonl (`*.jsonl`) and its + # timestamped rotations (`*.jsonl.`), the backend stdout/stderr log + # (`minds.log`) and its gzipped rotations (`minds.log..gz`), and the + # Electron main-process log (`electron.log`) and its gzipped rotations + # (`electron.log..gz`). Each must land in its own group, and the globs + # must not cross-match (e.g. `*.jsonl` must not pick up the rotated files, + # and `minds.log` must not pick up its own `.gz` rotations). logs_folder = tmp_path / "logs" logs_folder.mkdir() (logs_folder / "minds-events.jsonl").write_text("live\n") (logs_folder / "minds-events.jsonl.20250101120000123456").write_text("rotated\n") - (logs_folder / "minds.log").write_text("electron\n") + (logs_folder / "minds.log").write_text("backend\n") + (logs_folder / "minds.log.20250101120000123.gz").write_bytes(b"backend-rotated") + (logs_folder / "electron.log").write_text("electron\n") + (logs_folder / "electron.log.20250101120000123.gz").write_bytes(b"electron-rotated") uploader = ErrorAttachmentsS3Uploader(log_attachment_groups=_MINDS_LOG_ATTACHMENT_GROUPS) try: @@ -129,9 +135,20 @@ def test_collect_external_attachments_classifies_flat_minds_log_layout(tmp_path: except ValueError as exception: groups, callbacks = uploader.collect_external_attachments(exception=exception, logs_folder=logs_folder) - assert set(groups) == {"", "live_logs", "rotated_logs", "electron_logs"} + assert set(groups) == { + "", + "live_logs", + "rotated_logs", + "backend_logs", + "backend_rotated_logs", + "electron_logs", + "electron_rotated_logs", + } assert len(groups["live_logs"]) == 1 assert len(groups["rotated_logs"]) == 1 + assert len(groups["backend_logs"]) == 1 + assert len(groups["backend_rotated_logs"]) == 1 assert len(groups["electron_logs"]) == 1 - # one callback per upload: traceback + the three log files. - assert len(callbacks) == 4 + assert len(groups["electron_rotated_logs"]) == 1 + # one callback per upload: traceback + the six log files. + assert len(callbacks) == 7 diff --git a/vendor/mngr/apps/minds/package.json b/vendor/mngr/apps/minds/package.json index 5f5fd696..28f312b0 100644 --- a/vendor/mngr/apps/minds/package.json +++ b/vendor/mngr/apps/minds/package.json @@ -20,7 +20,7 @@ "dependencies": { "@sentry/electron": "^7.13.0", "@todesktop/runtime": "^1.6.0", - "latchkey": "^2.20.0", + "latchkey": "^2.20.2", "smol-toml": "^1.6.1" }, "devDependencies": { diff --git a/vendor/mngr/apps/minds/pnpm-lock.yaml b/vendor/mngr/apps/minds/pnpm-lock.yaml index e3b9e16d..9dd2c47f 100644 --- a/vendor/mngr/apps/minds/pnpm-lock.yaml +++ b/vendor/mngr/apps/minds/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^1.6.0 version: 1.6.4 latchkey: - specifier: ^2.20.0 - version: 2.20.0 + specifier: ^2.20.2 + version: 2.20.2 smol-toml: specifier: ^1.6.1 version: 1.6.1 @@ -292,8 +292,8 @@ packages: resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} engines: {node: '>=10.10.0'} - '@imbue-ai/detent@1.8.0': - resolution: {integrity: sha512-YSwXcdWYnSRRtEG8FV+yIguwFBA8Hy+g4WFfisBNiimcc8tH52EQSJ2InA06z8X1L6W1zNV285tG0EiWWZrr1w==} + '@imbue-ai/detent@1.9.0': + resolution: {integrity: sha512-LbzPZNWx+v4JXLw9/v52dlhwO/3ou/jckpsfFjXDyoBbmrMrBBlaRDgwgvoBxy3s0we+8jIM8N82ftnxJGzuZA==} engines: {node: '>=20'} hasBin: true @@ -1970,8 +1970,8 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - latchkey@2.20.0: - resolution: {integrity: sha512-jlCdWTa/DksDTqr+qtU5+T5zzFtuwDP7fPumZoKan5hzXphDN/BLSaLsv3Z/QR5UjQwngvp4Ou3CDWCcvuyUNg==} + latchkey@2.20.2: + resolution: {integrity: sha512-mYTVeF4m6ZHnFvVSnSkVcLF6CFUmh9wnUCbLwmM/ocwghETgzfUKVy32IE87jvtDasq68sOsUJmysbDyHqrEdA==} engines: {node: '>=20'} hasBin: true @@ -3266,7 +3266,7 @@ snapshots: '@humanwhocodes/momoa@2.0.4': {} - '@imbue-ai/detent@1.8.0': + '@imbue-ai/detent@1.9.0': dependencies: '@cfworker/json-schema': 4.1.1 commander: 14.0.3 @@ -5137,9 +5137,9 @@ snapshots: dependencies: json-buffer: 3.0.1 - latchkey@2.20.0: + latchkey@2.20.2: dependencies: - '@imbue-ai/detent': 1.8.0 + '@imbue-ai/detent': 1.9.0 '@napi-rs/keyring': 1.2.0 commander: 12.1.0 playwright: 1.60.0 diff --git a/vendor/mngr/apps/minds/scripts/electron_full_flow_e2e.py b/vendor/mngr/apps/minds/scripts/electron_full_flow_e2e.py index 5b77ac4a..5be04ab9 100644 --- a/vendor/mngr/apps/minds/scripts/electron_full_flow_e2e.py +++ b/vendor/mngr/apps/minds/scripts/electron_full_flow_e2e.py @@ -50,7 +50,13 @@ def main() -> None: logger.error('No MINDS_ROOT_NAME activated. Run: eval "$(uv run minds env activate dev-josh-1)"') sys.exit(2) # Keep the local-Docker provider on the stock runtime and silence Modal noise. - os.environ["MNGR__PROVIDERS__DOCKER__DOCKER_RUNTIME"] = "runc" + # MINDS_DOCKER_RUNTIME_DEFAULT pins the create form / API default to runc so + # minds never stacks the `docker_runsc` create-template -- the only way runsc + # gets selected. (A provider-config env var like + # MNGR__PROVIDERS__DOCKER__DOCKER_RUNTIME cannot help: an explicitly stacked + # template's docker_runtime outranks it.) The Electron child inherits this via + # _build_electron_env, which copies os.environ. + os.environ["MINDS_DOCKER_RUNTIME_DEFAULT"] = "RUNC" os.environ["MNGR__PROVIDERS__MODAL__IS_ENABLED"] = "false" # Materialize the paired DEFAULT_WORKSPACE_TEMPLATE worktree (clone paired branch or main + vendor diff --git a/vendor/mngr/apps/minds/test/unit/log-rotation.test.js b/vendor/mngr/apps/minds/test/unit/log-rotation.test.js new file mode 100644 index 00000000..f9d91e5e --- /dev/null +++ b/vendor/mngr/apps/minds/test/unit/log-rotation.test.js @@ -0,0 +1,119 @@ +// Unit tests for the size-based log rotation helper. +// +// Run with: pnpm --dir apps/minds test:unit (or: node --test test/unit/) +// +// log-rotation.js is plain node (no Electron), so it is testable directly. These +// lock in the load-bearing behavior: rotation triggers at the size threshold, +// rotated files are gzipped and pruned to the retention count, and the timestamp +// suffix is fixed-width so a lexicographic sort is chronological. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const zlib = require('node:zlib'); +const { createRotatingLogStream, rotationTimestamp, pruneRotated } = require('../../electron/log-rotation'); + +function tempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'log-rotation-test-')); +} + +// Wait for the background gzip (streamed, async) of a just-rotated file to land. +function waitForGz(dir, baseName, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const poll = () => { + const gz = fs.readdirSync(dir).filter((n) => n.startsWith(baseName + '.') && n.endsWith('.gz')); + if (gz.length > 0) return resolve(gz); + if (Date.now() >= deadline) return reject(new Error('gzip did not appear in time')); + setTimeout(poll, 20); + }; + poll(); + }); +} + +// The rotating stream is async (fs.WriteStream), so a just-written line reaches +// disk on a later tick. Poll the file until it equals the expected content. +function waitForFileContent(filePath, expected, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const poll = () => { + let content = ''; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + content = ''; + } + if (content === expected) return resolve(content); + if (Date.now() >= deadline) return reject(new Error(`file never became ${JSON.stringify(expected)} (was ${JSON.stringify(content)})`)); + setTimeout(poll, 20); + }; + poll(); + }); +} + +test('rotationTimestamp is a fixed-width, chronologically-sortable UTC string', () => { + const earlier = rotationTimestamp(new Date('2025-01-02T03:04:05.006Z')); + const later = rotationTimestamp(new Date('2025-01-02T03:04:05.007Z')); + assert.equal(earlier, '20250102030405006'); + assert.equal(earlier.length, 17); + assert.ok(earlier < later, 'later timestamp must sort after the earlier one'); +}); + +test('writes below the threshold do not rotate', async () => { + const dir = tempDir(); + const filePath = path.join(dir, 'electron.log'); + const stream = createRotatingLogStream({ filePath, maxSizeBytes: 1000, maxRotatedCount: 5 }); + stream.write('a'.repeat(100) + '\n'); + stream.write('b'.repeat(100) + '\n'); + await stream.end(); // resolves once the async stream has flushed + + assert.equal(fs.readdirSync(dir).length, 1, 'no rotation expected'); + assert.ok(fs.readFileSync(filePath, 'utf8').includes('aaaa')); +}); + +test('crossing the threshold rotates, gzips the old file, and starts a fresh one', async () => { + const dir = tempDir(); + const filePath = path.join(dir, 'electron.log'); + const stream = createRotatingLogStream({ filePath, maxSizeBytes: 200, maxRotatedCount: 5 }); + // The first write pushes tracked size past the threshold; the SECOND write sees + // the over-size file and triggers rotation, then is replayed into the fresh file. + stream.write('x'.repeat(250) + '\n'); + stream.write('fresh-line\n'); + + const gz = await waitForGz(dir, 'electron.log'); + assert.equal(gz.length, 1); + // The rotated (gzipped) file holds the pre-rotation content... + const rotatedContent = zlib.gunzipSync(fs.readFileSync(path.join(dir, gz[0]))).toString(); + assert.ok(rotatedContent.includes('xxxx')); + // ...and the fresh file eventually holds only what was written after rotating. + const currentContent = await waitForFileContent(filePath, 'fresh-line\n'); + assert.equal(currentContent, 'fresh-line\n'); + await stream.end(); +}); + +test('pruneRotated keeps only the newest N gzipped rotations', () => { + const dir = tempDir(); + // Fixed-width timestamps => lexicographic order == chronological order. + const names = [ + 'electron.log.20250101000000001.gz', + 'electron.log.20250101000000002.gz', + 'electron.log.20250101000000003.gz', + 'electron.log.20250101000000004.gz', + ]; + for (const name of names) fs.writeFileSync(path.join(dir, name), 'x'); + // A non-matching sibling and the live file must be left untouched. + fs.writeFileSync(path.join(dir, 'electron.log'), 'live'); + fs.writeFileSync(path.join(dir, 'minds.log.20250101000000001.gz'), 'other'); + + pruneRotated(dir, 'electron.log', 2); + + const remaining = fs.readdirSync(dir).sort(); + assert.deepEqual(remaining, [ + 'electron.log', + 'electron.log.20250101000000003.gz', + 'electron.log.20250101000000004.gz', + 'minds.log.20250101000000001.gz', + ]); +}); diff --git a/vendor/mngr/apps/minds/test_snapshot_resume.py b/vendor/mngr/apps/minds/test_snapshot_resume.py index 10f6f7e5..49aad9df 100644 --- a/vendor/mngr/apps/minds/test_snapshot_resume.py +++ b/vendor/mngr/apps/minds/test_snapshot_resume.py @@ -516,9 +516,14 @@ def _prepare_electron_workspace_inputs(tmp_path: Path, monkeypatch: pytest.Monke ensure_minds_env_defaults(setenv=monkeypatch.setenv) # No Modal creds here, so silence the Electron-spawned mngr's Modal discovery. monkeypatch.setenv("MNGR__PROVIDERS__MODAL__IS_ENABLED", "false") - # DEFAULT_WORKSPACE_TEMPLATE pins docker_runtime = "runsc" (gVisor), absent in CI / the sandbox; - # override to the default runtime (DEFAULT_WORKSPACE_TEMPLATE names this exact escape-hatch env var). - monkeypatch.setenv("MNGR__PROVIDERS__DOCKER__DOCKER_RUNTIME", "runc") + # Pin the local-docker workspace to runc; gVisor (runsc) is absent in CI / + # the sandbox. MINDS_DOCKER_RUNTIME_DEFAULT pins the create form / API default + # to runc so minds never stacks the `docker_runsc` create-template -- the only + # way runsc gets selected, now that the pinned DEFAULT_WORKSPACE_TEMPLATE `docker` template already + # defaults to runc. (A provider-config env var like + # MNGR__PROVIDERS__DOCKER__DOCKER_RUNTIME cannot help: an explicitly stacked + # template's docker_runtime outranks it.) + monkeypatch.setenv("MINDS_DOCKER_RUNTIME_DEFAULT", "RUNC") # The Electron-spawned mngr loads two project-config trees under # PYTEST_CURRENT_TEST: the host-side config (a throwaway opted-in copy built # here) and the DEFAULT_WORKSPACE_TEMPLATE worktree. The DEFAULT_WORKSPACE_TEMPLATE worktree is materialized ahead of time @@ -870,31 +875,6 @@ def test_backup_enable_repair_and_destination_change_on_resumed_workspace( container_name = running_workspace.container_name agent_id = AgentId(running_workspace.services_agent_id) - # Heal the persisted SSH endpoint before the first host-side connection. - # The fixture resumed the workspace with a raw `docker start`, and docker - # may re-publish the container's random SSH port on restart -- while the - # docker provider connects via the port persisted in its host record at - # snapshot time, so host-side execs can fail with "Unable to connect". - # Resuming through host-side `mngr start` on a *stopped* container re-runs - # the provider's SSH setup and rewrites the record with the live port - # (exactly what a real desktop resume does); a running container would - # early-return with the stale record, so stop it first. - stop_result = subprocess.run( - ["docker", "stop", container_name], capture_output=True, text=True, check=False, timeout=120 - ) - assert stop_result.returncode == 0, stop_result.stderr - # cwd must be a uv project for `uv run`; the config guard reads the - # opted-in MNGR_PROJECT_CONFIG_DIR rather than the repo's .mngr. - start_result = subprocess.run( - ["uv", "run", "mngr", "start", str(agent_id), "--quiet"], - cwd=str(_REPO_ROOT), - capture_output=True, - text=True, - check=False, - timeout=600, - ) - assert start_result.returncode == 0, (start_result.stdout, start_result.stderr) - data_dir = tmp_path / "minds-data" data_dir.mkdir() paths = WorkspacePaths(data_dir=data_dir) diff --git a/vendor/mngr/apps/modal_litellm/README.md b/vendor/mngr/apps/modal_litellm/README.md index d2df3e41..84905cac 100644 --- a/vendor/mngr/apps/modal_litellm/README.md +++ b/vendor/mngr/apps/modal_litellm/README.md @@ -91,6 +91,10 @@ even on litellm versions whose bundled price map predates a model. The model list lives in `apps/modal_litellm/app.py` (`LITELLM_CONFIG`) and is mirrored in `litellm_proxy/config.yaml`; `config_drift_test.py` fails if the two diverge. +Fable ($10 / $50 per 1M input / output): + +- `claude-fable-5` + Opus (current price tier, $5 / $25 per 1M input / output): - `claude-opus-4-8` (latest Opus) diff --git a/vendor/mngr/apps/modal_litellm/app.py b/vendor/mngr/apps/modal_litellm/app.py index 0c4027a0..c6bfa6f6 100644 --- a/vendor/mngr/apps/modal_litellm/app.py +++ b/vendor/mngr/apps/modal_litellm/app.py @@ -68,6 +68,12 @@ # predates a model (e.g. claude-opus-4-8 only landed in litellm's price map # in the 1.88.0 pre-release line). MUST stay in sync with # litellm_proxy/config.yaml -- config_drift_test.py enforces this. +_FABLE_PRICING = { + "input_cost_per_token": 0.00001, + "output_cost_per_token": 0.00005, + "cache_creation_input_token_cost": 0.0000125, + "cache_read_input_token_cost": 0.000001, +} _OPUS_PRICING = { "input_cost_per_token": 0.000005, "output_cost_per_token": 0.000025, @@ -108,6 +114,8 @@ def _model_entry(model_name: str, pricing: dict[str, float]) -> dict[str, object LITELLM_CONFIG = { "model_list": [ + # Fable line. + _model_entry("claude-fable-5", _FABLE_PRICING), # Current Opus line. _model_entry("claude-opus-4-8", _OPUS_PRICING), _model_entry("claude-opus-4-7", _OPUS_PRICING), diff --git a/vendor/mngr/apps/modal_litellm/changelog/mngr-fable.md b/vendor/mngr/apps/modal_litellm/changelog/mngr-fable.md new file mode 100644 index 00000000..1e38b592 --- /dev/null +++ b/vendor/mngr/apps/modal_litellm/changelog/mngr-fable.md @@ -0,0 +1 @@ +Add `claude-fable-5` to the LiteLLM proxy model list with inline pricing ($10 / $50 per 1M input / output tokens, cache write 1.25e-5, cache read 1e-6, mirrored from litellm's price map), so workspaces using imbue_cloud virtual keys can run Claude Fable 5 with correct cost tracking. diff --git a/vendor/mngr/apps/remote_service_connector/UNABRIDGED_CHANGELOG.md b/vendor/mngr/apps/remote_service_connector/UNABRIDGED_CHANGELOG.md index ee0a9fa7..5c8f6dfa 100644 --- a/vendor/mngr/apps/remote_service_connector/UNABRIDGED_CHANGELOG.md +++ b/vendor/mngr/apps/remote_service_connector/UNABRIDGED_CHANGELOG.md @@ -4,6 +4,12 @@ Full, unedited changelog entries consolidated nightly from individual files in ` For a concise summary, see [CHANGELOG.md](CHANGELOG.md). +## 2026-07-11 + +The forever-claude-template repo is being renamed to default-workspace-template (with the `fct`/`FCT` shorthand expanded to `default_workspace_template`/`DEFAULT_WORKSPACE_TEMPLATE` forms). + +References in this project (comments, identifiers, docs) are mechanically updated by `scripts/rename_template_repo.py`. + ## 2026-07-06 Added a `POST /hosts/{host_db_id}/rename` endpoint that updates a leased host's mutable `host_name` column. Ownership is enforced (a host leased by another user returns 403; a missing or not-leased host returns 404) and the new name is validated against mngr's SafeName regex. This backs the new workspace-rename flow; the host's durable identity (its lease id) is unchanged. diff --git a/vendor/mngr/apps/remote_service_connector/changelog/mngr-fct-rename.md b/vendor/mngr/apps/remote_service_connector/changelog/mngr-fct-rename.md deleted file mode 100644 index f5c39e78..00000000 --- a/vendor/mngr/apps/remote_service_connector/changelog/mngr-fct-rename.md +++ /dev/null @@ -1,3 +0,0 @@ -The forever-claude-template repo is being renamed to default-workspace-template (with the `fct`/`FCT` shorthand expanded to `default_workspace_template`/`DEFAULT_WORKSPACE_TEMPLATE` forms). - -References in this project (comments, identifiers, docs) are mechanically updated by `scripts/rename_template_repo.py`. diff --git a/vendor/mngr/blueprint/dockview-named-layouts/plan-dockview-named-layouts.md b/vendor/mngr/blueprint/dockview-named-layouts/plan-dockview-named-layouts.md new file mode 100644 index 00000000..b25d042d --- /dev/null +++ b/vendor/mngr/blueprint/dockview-named-layouts/plan-dockview-named-layouts.md @@ -0,0 +1,80 @@ +# Plan: Named dockview layouts for the default workspace template + +## Overview + +- Replace the single implicit dockview layout (`workspace_layout/layout.json` in the system_interface of default-workspace-template) with multiple named layouts stored as separate JSON files, plus a small amount of workspace-level layout state (display names, last-active tracking). +- The template ships two default layout names, `desktop` and `mobile`. Defaults are lazy: the names always exist, and a layout with no saved content triggers the existing auto-open-welcome-chat behavior on the client, whose autosave then materializes the file. An existing `layout.json` is migrated to become `desktop`. +- Each browser client keeps its own "active layout" (persisted in localStorage, first-time default chosen by user agent: mobile vs desktop). All existing autosave/restore behavior is unchanged except that it targets the active layout's file. +- The "+" menu gains a bottom section with "Save layout...", "Load layout...", and "Delete layout..." dialogs, plus live cross-client sync (re-apply on save by another client, auto-switch on delete). +- Agents learn which client/layout a request came from via a new workspace-level append-only `events.jsonl` (message + layout-switch events) and a new `layout.py context` subcommand; mutating `layout.py` ops become layout-targeted (`--layout`, required) and only work when a connected client has that layout active — no server-side JSON mutation of layouts. + +## Expected behavior + +### Layout storage and defaults + +- Layouts are named, server-persisted dockview states (same `SavedLayout` shape as today: dockview JSON + panelParams), one JSON file per layout under the primary agent's `workspace_layout/` dir. Filenames are slugs; display names are free-form and preserved. A save whose slug collides with a *different* existing layout's slug is rejected with a clear error. +- `desktop` and `mobile` always exist as layout names, even before any content is saved. Loading a layout with no saved content behaves exactly like today's "no saved layout" path: the client auto-opens the initial welcome chat tab and its autosave materializes the file. +- On first request after upgrade, a legacy `workspace_layout/layout.json` is migrated to be the `desktop` layout's content; `mobile` starts fresh. No other migration. + +### Client behavior + +- On first connect ever (no localStorage selection), the client picks `mobile` if the user agent is mobile (`navigator.userAgentData?.mobile`, falling back to a UA-string regex), else `desktop`. If that layout name no longer exists, it falls back to the first existing layout. +- The selection persists per browser in localStorage; every subsequent connect restores that layout (falling back as above if it was deleted). +- Every dockview change auto-persists (existing 1.5 s debounce) to the client's active layout. Switching layouts (load, save-as, delete-fallback) flushes any pending autosave of the old layout first, so no changes are lost. +- The "+" menu (both the group-header button and the empty-state overlay) gains a divider and three items at the bottom: + - "Save layout...": dialog with the list of existing layouts and a free-text name field, prefilled with the active layout's name (one-click save-over-current). Uniform rule: after any save, the client is on the layout it saved to — saving under a new name creates it and switches, and overwriting a different existing layout switches too. + - "Load layout...": dialog listing all layouts; selecting one applies it and makes it the autosave target. + - "Delete layout...": dialog listing all layouts; the only guard is that the last remaining layout cannot be deleted. + - All three dialogs mark the client's active layout with "(current)". +- Live cross-client sync: when a client saves a layout, other connected clients with that same layout active re-apply it. When a layout is deleted, clients with it active automatically switch to the first remaining layout and show a brief notice. +- Echo suppression for live sync (both guards): the save broadcast carries the saving client's id and receivers suppress the autosave that their re-apply would trigger, and clients additionally skip saving/broadcasting when the serialized layout is unchanged from what the server last sent them (content-hash guard). + +### Event recording (agent context) + +- A workspace-level append-only events file lives next to the layouts (e.g. `workspace_layout/events/client_activity/events.jsonl`), following the repo's standard event envelope (timestamp, type, event_id, source). No rotation in v1. +- Every message sent through the UI appends a message event: message text (truncated at write time, e.g. first ~500 chars — full transcripts live elsewhere), time, target agent, client id (uuid minted per browser in localStorage), device kind from the UA (mobile/desktop), and the client's active layout at send time. +- Every layout switch appends a switch event (client id, device kind, old/new layout), regardless of initiator — user action, agent-driven `layout.py load`, or delete-fallback. +- Messages sent outside the UI (e.g. directly in tmux) have no metadata; the skill instructs the agent to fall back to the last active layout, mutate all layouts, or ask the user. + +### Agent-facing layout ops (`scripts/layout.py`) + +- Mutating ops (`open`, `split`, `close`, `move`, `rename`, `focus`, `maximize`, `restore`, `replace-url`) require an explicit `--layout `; omitting it is an error, and the skill instructs the agent to always pass it. The op broadcasts only to connected clients whose active layout matches; if none, it fails with a clear error the agent can relay (no server-side JSON mutation). +- If multiple clients share the target layout, all of them apply the op (they converge via autosave + live sync). +- Read-only `inspect` / `list` / `where` accept `--layout` and read that named layout's persisted file directly (works with no client connected). Default resolution when omitted: the last active layout. +- New `layout.py load `: switches the requesting client (resolved from message metadata; falls back to all clients when the requester is unknown) onto that layout, so the agent can then mutate it. An optional `--client ` targets a specific client explicitly (the agent can pick one after consulting `context`). +- New `layout.py context`: prints, per known client — client id, device kind from the UA (mobile/desktop), current layout, last-seen time, and the last ~5 messages (long messages truncated) — so the agent can figure out which client/layout a request refers to. +- The `manage-layout` skill is updated to document layout targeting, `context`, `load`, the `--layout` requirement, and the no-metadata fallback guidance. + +## Changes + +All changes land in the `default-workspace-template` repo (worked on via an `.external_worktrees/` checkout per monorepo convention), primarily `apps/system_interface`; a changelog entry is added there. No mngr monorepo code changes are expected beyond this blueprint (and vendor sync happens through the normal release flow). + +- **Server (`imbue/system_interface/server.py` + a new layouts module)** + - Replace the single-file GET/POST `/api/layout` with named-layout endpoints: list layouts (slug, display name, current-content flag), get/save a named layout, delete a named layout (guarding the last one), all rooted in the existing `workspace_layout/` dir. + - Slug validation/derivation from display names; reject slug conflicts on save. + - Legacy `layout.json` migration to `desktop` on first access. + - Track last-active layout and connected clients' active layouts via an in-memory registry keyed by WS connection: each client sends `{client_id, active_layout}` on WS open and on every switch, and its entry dies with the connection ("connected" = WS open). + - Append message events (in the message-send endpoint) and layout-switch events to the workspace-level `events.jsonl` per the standard envelope. + - WS broadcasts for live sync: "layout saved" (clients on it re-apply) and "layout deleted" (clients on it switch + notice), plus the agent-driven "load layout" op routed to the requesting client. + +- **Layout broadcast path (`imbue/system_interface/layout_ops.py`)** + - Mutating ops carry the target layout and are delivered only to matching clients; error when no client has the layout active. + - `inspect`/`list` resolve a named layout file (defaulting to last active). + - New `context` op assembled from `events.jsonl` (per-client recent messages, current layout, device kind, last-seen). + - New `load` op (switch requesting client, explicit `--client ` override, fall back to all clients). + +- **Frontend (`frontend/src/views/DockviewWorkspace.ts` + new dialog view(s), `models/AgentManager.ts`, `views/MessageInput.ts` or message-send path)** + - Client id (uuid) + active layout name in localStorage; UA-based first-time default with fall-back to first existing layout. + - Load/save against named-layout endpoints; autosave targets the active layout; flush pending autosave before any switch. + - "+" menu bottom section with the three dialogs (Save prefilled with current name; "(current)" markers; last-layout delete guard surfaced from the server). + - Handle new WS messages: re-apply on same-layout save by another client, auto-switch + notice on delete, agent-driven load, and layout-targeted layout ops (apply only when the target layout is active). + - Send client id + active layout with every chat message; report active layout over WS on connect and on switch. + +- **Agent tooling (`scripts/layout.py`, `.agents/skills/manage-layout/SKILL.md`)** + - Required `--layout` on mutating subcommands; optional on read-only ones; new `load` and `context` subcommands. + - Skill doc updates: layout targeting workflow (context → load if needed → mutate), no-metadata fallback guidance (last active layout / all layouts / ask). + +- **Tests** + - Server: named-layout CRUD, slug conflicts, migration, last-layout delete guard, event appends, last-active tracking. + - Layout ops: `--layout` routing/error paths, `context` assembly, `load` targeting. + - Frontend unit tests where the existing suite has coverage (e.g. UA default choice, slug/display handling in dialogs); e2e layout pipeline test updated for named layouts. diff --git a/vendor/mngr/blueprint/electron-log-and-crash-page/plan-electron-log-and-crash-page.md b/vendor/mngr/blueprint/electron-log-and-crash-page/plan-electron-log-and-crash-page.md new file mode 100644 index 00000000..4073f80c --- /dev/null +++ b/vendor/mngr/blueprint/electron-log-and-crash-page/plan-electron-log-and-crash-page.md @@ -0,0 +1,107 @@ +# Plan: Electron main-process logging + crashed-content-view recovery page + +## Refined prompt + +Plan improvements from the blank-screen-after-sleep diagnosis (Sentry event bc6d78e995bd46738bc517644c9e9c23, kanjun's 2026-07-09 report): + +* Persist Electron main-process logs to a new `electron.log` in `~/.minds/logs/` by teeing `console.log/warn/error` (timestamped) -- every existing call site captured, no rewrites +* Also log `uncaughtException` / `unhandledRejection` tracebacks to `electron.log` (log, then let default handling proceed) so main-process crashes are durably diagnosable +* Rotate BOTH `minds.log` and `electron.log` with a shared Electron-side rotation helper: 100MB threshold, keep 10 rotated files, gzip each rotated file after rotating (mirroring the jsonl scheme's sizes and timestamped-suffix naming) +* Do NOT touch the shared Python jsonl sink (`make_jsonl_file_sink` in `imbue_common`) -- jsonl rotation behavior is unchanged +* Error-report uploads include, per log, the current file plus the most recent rotation gzip; split the S3 attachment groups into accurate names (`backend_logs` for `minds.log`, `electron_logs` for `electron.log`) +* The Electron backend-down report path attaches both `minds.log` AND `electron.log` (today it attaches only the single newest `.log`), so a report filed while the backend is dead still shows why the backend died +* On content-view `render-process-gone`: log it, then show an Electron-local "Aw, Snap!"-style page in the content view with the crash reason/exit code, a Reload button (re-loads the pre-crash URL via the content-relay IPC bridge, which existing error pages already use), and a "Report a bug" button wired to the existing help flow +* No auto-reload (crash-loop safety, matching Chrome/Firefox/VS Code); the crash page is stateless -- repeated crashes show the same page each time +* Simple generic page styling to start (sad glyph + text + buttons); no pixel parity with the backend-served recovery pages +* Scope: content view only (chrome view, modal view, GPU `child-process-gone`, and `did-fail-load` handling are out of scope this round); `powerMonitor` suspend/resume logging is a noted follow-up + +## Overview + +* Kanjun's blank-screen-after-sleep incident (Sentry bc6d78e9) was undiagnosable from uploaded logs because the Electron main process logs nothing durable: all ~60 call sites are bare `console.*` that vanish in packaged builds, and `minds.log` (despite being uploaded as "electron_logs") contains only the Python backend's stdout/stderr. +* The likely mechanism -- the workspace content view's render process dying over sleep and leaving its pinned-white background -- is invisible and unrecoverable today: no `render-process-gone` handler exists, so the view stays white until the user manually navigates Home and back. +* Fix the observability gap with a new `electron.log` (console tee + uncaught-exception logging), rotated and gzipped alongside a newly-rotated `minds.log`, and uploaded through the existing bug-report S3 pipeline. +* Fix the recovery gap with a Chrome "Aw, Snap!"-style local crash page in the content view: manual Reload button rather than auto-reload, because if the page caused the crash auto-reload becomes a crash loop (this matches Chrome/Firefox/VS Code, and avoids retry-cap machinery entirely). +* Known Electron pitfall respected: never call `loadURL` synchronously inside the `render-process-gone` handler (electron#19887 -- it can crash the whole app); the crash-page navigation is deferred. +* Extend the recovery to the window's other two renderers (chrome and overlay views, which run in separate processes and can die independently over the same sleep), and close the observability gap that let the incident stay invisible: renderer deaths were never reported to Sentry (the SDK's default only breadcrumbs `killed`/`crashed`/`oom`; our handlers only logged), and the friendly crash page removes the user's motivation to file the manual report that surfaced it in the first place. + +## Expected behavior + +* Every `console.log/warn/error` from the Electron main process appears (timestamped, level-tagged) in `~/.minds/logs/electron.log`, in addition to stdout/stderr as today. Dev-terminal behavior is unchanged. +* Uncaught exceptions and unhandled rejections in the main process are written to `electron.log` with full stack traces before default handling (Sentry capture, process exit) proceeds unchanged. +* `minds.log` and `electron.log` each rotate at 100MB: the current file is renamed with a timestamp suffix, gzipped (e.g. `minds.log.20260709195046123456.gz`), and at most 10 rotated files per log are kept. No more unbounded 600MB log files. +* Bug reports (the `/help` flow with logs opted in) upload four log artifacts to S3 instead of two-ish today: current `minds.log`, current `electron.log` (each gzipped at upload), plus the most recent rotation gzip of each (uploaded once and cached, since rotated files are immutable). The Sentry event context shows them under accurate group names: `uploaded_files_backend_logs` / `uploaded_files_electron_logs` (+ `_rotated` variants), alongside the unchanged jsonl groups. +* Backend-down manual reports (the full-app error takeover) attach the tails of `minds.log`, `electron.log`, and the newest jsonl -- so a report filed while the backend is dead shows both why the backend died and what the shell did about it. +* When a workspace content view's renderer dies (any reason except `clean-exit`), the user sees a local "Aw, Snap!"-style page in the content area within a moment: sad glyph, "This workspace view crashed" with the reason and exit code, a Reload button, and a "Report a bug" button. The crash is also logged to `electron.log` with reason/exit code/URL. +* Clicking Reload re-loads the pre-crash workspace URL in the content view (spawning a fresh renderer). If it crashes again, the same page reappears -- stateless, the user is the loop-breaker. +* Clicking "Report a bug" opens the existing help/report modal (same flow the workspace-recovery page uses), scoped to the affected workspace. +* The content-view crash page occupies only the content view and is replaced by any subsequent navigation (e.g. the recovery flow or the user going Home); the window title, sidebar, and all backend-driven health/recovery flows are unchanged. +* When the chrome (titlebar) renderer dies, the user sees a miniaturized error strip in the titlebar with a Reload button instead of a blank white bar; the workspace content view keeps running, and Reload restores a fully-populated titlebar. When the overlay renderer dies it is silently reloaded warm, so the next sidebar/inbox/help open works normally. +* Out-of-memory renderer deaths (`oom`) are reported to Sentry automatically (subject to the existing `report_unexpected_errors` opt-in), labeled by which view died; native crashes already flow as minidumps, and sleep/external kills (`killed`) are deliberately not reported as events (unactionable, noisy) but remain in `electron.log` and any manual report. + +## Changes + +### Electron main-process logging (`apps/minds/electron/`) + +* New logger module (e.g. `logger.js`): opens an append stream to `/electron.log`, wraps `console.log/warn/error` to tee formatted, timestamped lines into it (original console behavior preserved), and registers `process.on('uncaughtException'/'unhandledRejection')` listeners that log the stack then leave default/Sentry handling untouched. Initialized as the very first thing in `main.js` (before `initSentry()`), so startup output is captured. +* New rotation helper (shared by `electron.log` and `minds.log`): on stream open and on a size check during writes, when the file exceeds 100MB rename it to `.` (matching the jsonl timestamp format), gzip it (then remove the uncompressed rename), and prune to the 10 newest rotated files. Only the Electron main process writes these files, so no cross-process locking is needed (unlike the Python sink). +* `backend.js`: route the existing `minds.log` write stream through the rotation helper instead of a bare `fs.createWriteStream`. + +### Crash page for the content view (`apps/minds/electron/`) + +* `main.js` `wireContentViewEvents`: track the last committed content URL per bundle (from the existing `did-navigate` handler), and add a `render-process-gone` handler that logs reason/exit code/URL, ignores `clean-exit`, and navigates the content view to the local crash page on a deferred tick (never synchronously -- electron#19887). Both content-view creation sites already call `wireContentViewEvents`, so post-retry views are covered automatically. +* New `crashed.html` (Electron-local, loaded into the content view so it runs the existing `content-relay-preload.js`): generic sad-glyph page rendering the reason/exit code passed via query params, with Reload and "Report a bug" buttons. +* Reload button: posts a new allowlisted message (e.g. `minds:reload-crashed-view`) relayed by `content-relay-preload.js` to a new `ipcMain` channel that re-loads the bundle's stored pre-crash URL -- same pattern as the existing `open-help` / `open-request-modal` relays. +* Report button: posts the existing `minds:open-help` message with the workspace agent id (passed to the page via query param), reusing the existing channel and validation unchanged. + +### Upload plumbing + +* `apps/minds/imbue/minds/utils/sentry/core.py`: replace the single mislabeled `electron_logs` (`*.log`) attachment group with four accurate groups -- `backend_logs` (`minds.log`, mutable, gzip-on-upload), `electron_logs` (`electron.log`, mutable, gzip-on-upload), and per-log rotated groups (`minds.log.*.gz` / `electron.log.*.gz`, `max_file_count=1`, immutable, `is_compressed=False` since they are pre-gzipped). Fix the stale layout comment while there. No changes to the shared uploader or the jsonl groups. +* `apps/minds/electron/sentry.js` `collectLogAttachments`: attach `minds.log` and `electron.log` explicitly (plus the newest jsonl, as today) instead of "the newest single `.log`", within the existing compressed-attachment budget. + +### Chrome- and modal-view crash recovery (`apps/minds/electron/`) + +The content view is not the only per-window renderer that can die over a long sleep. Each window bundles three `WebContentsView`s -- `chromeView` (the titlebar/menu chrome), `contentView` (the workspace), and `modalView` (the warm overlay host for the sidebar/inbox/help) -- and they run in *separate* renderer processes: the content view has its own session (partition `persist:workspace-content`) and origin, while the chrome and overlay views share the default session and backend origin. So the chrome renderer can die on its own, leaving a white, dead titlebar with **no** recovery affordance at all (worse than the content case, which at least gets a crash page with a button). Confirmed in practice: killing the chrome renderer leaves the workspace content running but the menu bar blank. + +Recovery for these two views is deliberately *different* from the content view, because they host our own trusted, fixed first-party pages (`/_chrome`, `/_chrome/overlay`) rather than foreign workspace content, and because of how the views are layered. + +* **Chrome view -- miniaturized in-titlebar error strip.** Add a `render-process-gone` handler on `chromeView` (ignore `clean-exit`, defer the navigation a tick per electron#19887, mirror the content handler's guards). The chrome view is a full-window *underlay*; the content view overlays it inset below the ~38px titlebar (`computeBundleViewBounds`), so when only the chrome renderer dies the **only visible region of the chrome view is the top titlebar strip**. The recovery UI therefore lives there: + * New local `chrome-crashed.html` (loaded into the chrome view, which runs the full `preload.js` bridge): a compact single row anchored to the top titlebar strip -- brand maroon, a short message ("The menu bar stopped responding") and a prominent manual **Reload** button. No reason/exit-code detail (it won't fit, and that data already goes to `electron.log` + Sentry). + * **Manual** Reload, not auto -- for the same loop-safety as the content view (if `/_chrome` itself is what crashes, the user is the loop-breaker) and for behavioral consistency across the two crash surfaces. + * The chrome view is trusted first-party, so Reload calls a dedicated `reload-chrome` `ipcMain` channel directly (no content-relay indirection). The handler reloads `/_chrome`; the existing `did-finish-load` handler re-primes the fresh chrome from `latestChromeState`, so the bar returns fully populated. + * Track a `bundle.isChromeCrashed` flag mirroring `isContentCrashed`, and leave the content view and its bounds untouched -- the workspace stays fully usable while the bar is being restored. + +* **Modal/overlay view -- silent warm reload.** Add a `render-process-gone` handler on `modalView` (ignore `clean-exit`). This view is hidden whenever no overlay is open, so there is no visible page to show: just call the existing `loadOverlayHost(bundle)` to reload `/_chrome/overlay` warm, and reset any open-modal state (`closeModal`) so the next sidebar/inbox/help open lands on a fresh host. No new HTML. + +Caveats to handle: + +* On frameless platforms (Linux/Windows, `frame: false`) the window min/max/close controls are custom-drawn *inside* the chrome content, so they vanish with a dead chrome renderer until Reload restores them (macOS traffic lights are OS-drawn via `titleBarStyle: hiddenInset`, so they are unaffected). This is inherent to any chrome-renderer death; the prominent Reload button is the recovery path. +* Chrome dying *and* the backend being down at the same time is rare (two failures at once); keep the `reload-chrome` handler simple (attempt `/_chrome`; the existing backend-down detection/takeover already covers "backend is gone") rather than special-casing it. + +### Sentry reporting of renderer and child-process deaths (`apps/minds/electron/sentry.js`) + +The original blank-screen-after-sleep incident only reached us because a user hit a scary blank screen and filed a *manual* report; nothing was captured automatically. Two compounding gaps cause that, and the content-view crash page makes it worse (a friendly Reload page removes the user's motivation to report, so without automatic capture these incidents become *more* invisible): + +1. `@sentry/electron`'s default `childProcess` integration (active via the default integrations, `DEFAULT_OPTIONS.events = ['abnormal-exit', 'launch-failed', 'integrity-failure']`) turns `killed`, `crashed`, and `oom` renderer deaths into **breadcrumbs only** -- never standalone events. A renderer reaped over sleep reports `reason=killed` (verified in staging `electron.log`: `reason=killed, exitCode=9`), so it is never sent. +2. Our own `render-process-gone` handlers only `console.error` -- they never call `Sentry.capture*`. + +Fix, honoring the "only report what we can act on" filter: + +* In `initSentry`, replace the default `childProcessIntegration()` with `childProcessIntegration({ events: [...defaults, 'oom'] })` so **`oom`** is captured as a Sentry event across *all* processes (content/chrome/modal renderers plus GPU/utility). An OOM does not produce a Crashpad minidump, so this is a unique signal; it is frequently our own memory leak (especially in the first-party chrome view), so an individual event is diagnosable and a post-release rate spike is an actionable regression signal. +* **Do not** add `crashed` to the events list. A native renderer crash already produces a minidump event via the default `SentryMinidump` integration (that is precisely why the SDK's default events list omits `crashed`); adding it to `childProcess` would double-report every crash in packaged builds. (Caveat: the dev launcher sets `MINDS_DISABLE_CRASHPAD`, so `crashed` is unreported in dev runs -- acceptable, since dev crashes are observed live.) +* **Do not** capture `killed` as an event. It is dominated by OS/sleep reaping and other external kills, is individually unactionable, and would be noisy. It stays a breadcrumb, still lands in `electron.log` (the `[content-crash]` / `[chrome-crash]` / `[overlay-crash]` lines), and still uploads with any manual bug report -- so we are not blind to sleep-deaths, we just do not create unactionable Sentry issues for laptops going to sleep. +* Set `getRendererName(contents)` in `Sentry.init` (maps a `webContents` to its bundle view) so renderer-death events are labeled `chrome` / `content` / `modal` for triage. +* No new gating: the existing `beforeSend` already drops events when `report_unexpected_errors` is off, so these auto-captures honor the same opt-in. +* Optional enrichment (decide during implementation): additionally `Sentry.captureException`/`captureMessage` inside the content-view `render-process-gone` handler with the workspace URL/id, for richer per-workspace context than the app-global hook can attach. Baseline is the SDK-level broadening above. + +### Acceptance criteria + +* Killing the **content** renderer (`forcefullyCrashRenderer` / SIGKILL) still shows `crashed.html` with a working Reload (unchanged). +* Killing the **chrome** renderer shows the miniaturized titlebar error strip with a Reload button; the workspace content view keeps running; clicking Reload restores a fully-populated `/_chrome` bar. On macOS the traffic lights remain usable throughout. +* Killing the **modal/overlay** renderer is invisible while idle; opening the sidebar/inbox/help afterward works (the host silently reloaded). +* With `report_unexpected_errors` on: an `oom` renderer death produces a Sentry event labeled by view; a `killed` death produces **no** event (breadcrumb only) but still appears in `electron.log`. (A native `crashed` death is covered separately by the minidump integration in packaged builds.) + +### Follow-ups (out of scope, noted for later) + +* `powerMonitor` suspend/resume logging (wake timestamps in `electron.log`). +* GPU/utility process crash *pages* (their deaths are now reported to Sentry via the broadened `childProcess` integration, but there is no user-facing recovery surface for them) and `did-fail-load` handling on the content view. +* Rotation for the Python jsonl logs' gzip story, if ever wanted -- deliberately untouched here to avoid changing shared `imbue_common` code. diff --git a/vendor/mngr/blueprint/forward-services-cache/plan-forward-services-cache.md b/vendor/mngr/blueprint/forward-services-cache/plan-forward-services-cache.md new file mode 100644 index 00000000..2aa46830 --- /dev/null +++ b/vendor/mngr/blueprint/forward-services-cache/plan-forward-services-cache.md @@ -0,0 +1,111 @@ +# Plan: `mngr forward` last-known service-map cache (fast first-load) + +Implements the "seed from last-known" fast-first-load fix for `mngr forward`, +located entirely in the `mngr_forward` plugin. Root cause, live evidence, and the +alternatives considered are captured below so this spec stands alone. + +## Overview + +- **Problem:** on launch, `mngr forward` starts with an empty routing table. + `resolve()` returns `None` — the 503 "Loading workspace" loader + (`resolver.py:153-167`, `server.py:579-582`) — until the slow per-agent + `mngr event … services --follow` stream (`stream_manager.py:432-535`) delivers the + agent's service URL. Membership + SSH info arrive fast (~t+2.5s) from the cached + discovery-snapshot replay (`--observe-via-file`); only the service URL is the + laggard. Measured live (staging/prod, read-only): a cold single stream takes + ~10s; under spawn contention across many `is_primary` agents the tail workspace + waits ~50-55s. Local Docker minds load in ~20s. +- **Where service URLs live:** in-container services self-register (once they bind + a port) into `/agents//events/services/events.jsonl` on the host; + the forward reads them only via the per-agent SSH-backed `mngr event` stream. + They are absent from the discovery snapshot today (verified live: imbue_cloud + `certified_data` carries no `url`/`services`). +- **Fix:** persist the resolver's per-agent service map to disk while the plugin + runs, and **seed** the resolver from that cache at startup. The route becomes + resolvable as soon as fresh discovery supplies membership + SSH info (~t+3s), + instead of after the cold event stream connects. The live stream still runs and + overwrites the seed as soon as it delivers. +- **Why in `mngr_forward` (not `mngr event` / not discovery):** the resolver + already holds the derived `{service → url}` map and already serializes it on + every change; caching a small derived snapshot through that existing seam is far + smaller than adding a durable raw-event mirror to the generic `mngr event` + command or extending the discovery snapshot schema + every provider. It is also + provider-agnostic by construction. +- **Why stale seeds are safe here:** `resolve()` only uses a service entry for an + agent that *this launch's* fresh discovery lists as known and reachable + (`resolver.py:157-159`), so a cache entry for a destroyed/replaced agent is + never consulted. Service ports are fixed (measured: `system_interface` pinned to + `localhost:8000` across container restarts), so a stale cached URL is almost + always still correct; the SSH tunnel target is always fresh from discovery. +- **Scope decisions (from Q&A):** no provisional/grace window (accept the narrow + fixed-port risk); no cache freshness bound (always seed, rely on + discovery-membership gating + live-stream overwrite); an empty/absent cache must + behave exactly as today. + +## Expected behavior + +- On relaunch with a warm cache, a restored window onto a **remote** mind whose + container is up loads in ~t+3s (bounded by discovery-snapshot replay) instead of + ~50-55s; local minds improve similarly. +- The seeded route is corrected automatically: when the live `mngr event … services` + stream connects (~10s+), it replaces the seeded map with current data via the + existing `update_services` path — a full replace, so a stale entry cannot linger. +- A seeded route is only ever served for an agent that this launch's discovery + confirms is known and has SSH info; otherwise `resolve()` returns `None` and the + loader shows exactly as today. +- **First-ever launch / just-created agent / cold container (no cache entry):** + identical to today — the resolver stays empty for that agent until the live + stream delivers; no regression. +- **Stale seed edge case (rare):** if a cached URL is wrong (same agent id, + running, but a service moved ports), the proxy dials a dead port and emits the + normal `CONNECT_ERROR` backend-failure envelope until the live stream corrects + (~10s). With no grace window this can, in that narrow window, feed minds' STUCK + detector (5s threshold) — accepted as near-impossible for fixed-port services. +- No change to the envelope stream minds consumes, to auth, to the per-agent live + streams, to discovery, or to any provider. No new CLI flags required for the + default minds flow. + +## Changes + +- **Persist the resolver service map.** While the plugin runs, write the current + per-agent `{service → url}` map to a cache file under the existing plugin state + dir (`$MNGR_HOST_DIR/plugin/forward/`, alongside the auth signing key). The write + hooks the resolver's existing "services changed" mutation point (the same point + that already emits `resolver_snapshot`), so the cache always reflects the live + map. Writes are atomic (temp file + rename) and tolerant of I/O errors (best + effort; a failed write must never break forwarding). +- **Seed at startup.** At plugin startup (observe / `--observe-via-file` modes), + before/alongside starting the streams, load the cache file and populate the + resolver's per-agent service map from it. Seeding only fills the service map; + `resolve()` still waits on discovery-supplied membership + SSH info, so no route + is served for an agent discovery hasn't confirmed. +- **Cache lifecycle / invalidation.** Removing an agent from the resolver + (destruction / bulk discovery reconcile) drops its cache entry, mirroring the + existing in-memory `_services_by_agent` removal. No freshness bound and no + age-based eviction (per Q&A). The cache is scoped per `MNGR_HOST_DIR`, so + staging/prod/local minds keep separate caches automatically. +- **No behavior when empty/absent.** A missing, empty, or unreadable cache file is + a no-op seed — the resolver starts empty for those agents, exactly as today. +- **Alternatives considered, not built (future options):** + + - *Discovery-snapshot seed:* have discovery read each agent's `events/services` + file during its existing per-host visit and carry the map on the snapshot, so + the resolver seeds from replay with poll-fresh data and no per-agent SSH. + Fresher than the cache, but heavier — touches mngr core discovery, the + snapshot schema, and every provider's listing script. + + - *Priority-agent hint:* an app-agnostic `--priority-agent` flag so the + open-window agent's live stream spawns first / uncontended. Removes spawn + contention but floors at the measured ~10s single-stream cost, so it cannot + reach the cache's ~3s. + + - Also not built: persistent per-agent streams or supervisor changes, and any + grace/provisional-route logic (Q&A chose to accept the narrow fixed-port risk). +- **Residual open item to verify during implementation:** minds' recovery-redirect + freshness math compares outage onset against discovery snapshot timestamps + (`system_interface_health.py`); confirm a seeded route does not perturb that + comparison. +- **Tests:** cover seed-from-cache populates the resolver; live stream overwrites a + seeded entry; a seeded entry is not served until discovery marks the agent known + with SSH info; destruction drops the cache entry; empty/absent/corrupt cache is a + safe no-op. A changelog entry under `libs/mngr_forward/changelog/` is required. diff --git a/vendor/mngr/blueprint/imbue-cloud-sticky-agent-labels/plan-imbue-cloud-sticky-agent-labels.md b/vendor/mngr/blueprint/imbue-cloud-sticky-agent-labels/plan-imbue-cloud-sticky-agent-labels.md new file mode 100644 index 00000000..58eb9eb4 --- /dev/null +++ b/vendor/mngr/blueprint/imbue-cloud-sticky-agent-labels/plan-imbue-cloud-sticky-agent-labels.md @@ -0,0 +1,47 @@ +# Plan: imbue_cloud sticky agent labels (husk fix) + +## Refined prompt + +Provider-side fix for the imbue_cloud husk bug: persist last-known agent identity (name + certified_data) per host under the provider's existing `hosts//` state dir, and re-attach it when discovery falls back to lease-only synthesis, so transiently-unreachable workspaces keep `is_primary` and never vanish from the sidebar or 404 on restart. + +* Persist to disk (survives app/forward relaunch — the observed 54c9 failure mode); labels/certified_data only, not the full raw listing +* Re-attach in both fallback sites: outer-SSH-unreachable (CRASHED and UNAUTHENTICATED) and the empty-agents synthesis on a successful pass +* Host state stays truthfully CRASHED/UNAUTHENTICATED — cached data restores identity, not liveness (confirmed: no consumer treats certified_data presence as reachability) +* Cache deleted on destroy via the existing `_cleanup_local_host_state` rm-tree +* No minds-side changes — minds trusts providers +* The unreachable-host fallback emits ALL agents cached from the last good pass (not just the lease's single agent), each with its cached certified_data — also preserving the system-services agents minds tracks per host +* Cached certified_data carries a synthetic `"stale": true` marker key so consumers can distinguish cached identity from a live listing +* Cache file is `hosts//last_known_agents.json` (agent_id -> {name, certified_data}), written with `atomic_write` on every successful discovery pass +* Tested with unit tests in `instance_test.py` (existing stub pattern) plus an integration-level test + +## Overview + +- imbue_cloud discovery currently emits a label-less "husk" agent whenever the outer SSH to a leased host is unreachable (or a successful listing returns zero agents); every consumer that filters on labels — most importantly minds' `is_primary` sidebar/restart guard — silently drops the workspace. +- Fix at the provider so all consumers (`mngr list`, the forward's `--agent-include`, minds) are covered in one place: remember each host's agents from the last successful listing and re-attach that identity in the fallback paths. +- Identity is persisted to disk in the provider's existing per-host state dir so it survives process and app relaunches — the exact scenario (fresh app start into a flaky network window) observed in production. +- Liveness reporting is unchanged: fallback hosts still surface as CRASHED/UNAUTHENTICATED with `failure_reason`; only the agents' identity (names + labels + data.json fields) is restored, marked stale. +- No mngr-core or minds changes; the fix is contained in `libs/mngr_imbue_cloud`. + +## Expected behavior + +- While a leased host is transiently unreachable (sleep/wifi blip, box outage), discovery keeps emitting the full set of agents last seen on that host, each with its last-known certified_data (labels, type, work_dir, ...) plus `"stale": true`, instead of one bare lease-stub agent. +- minds consequences (no minds changes needed): the workspace keeps `is_primary`, so it stays in the sidebar, `list_known_workspace_ids` keeps it, and the restart endpoint no longer 404s — a restart attempted while the host is truly unreachable now fails at the stop/start step with a real, retryable error message instead of "Unknown workspace". +- System-services agents (and any other non-primary agents on the host) also survive the unreachable window, so per-host tracking built on them keeps working. +- The same re-attachment applies when outer SSH works but the listing contains zero agents (stopped container / empty data.json), and when the failure is an auth rejection (UNAUTHENTICATED) rather than CRASHED. +- `mngr list` during an unreachable window shows the host as CRASHED/UNAUTHENTICATED with its real agents (marked stale via certified_data) rather than a single synthetic agent named by its id. +- First-ever discovery of a host with no cache yet behaves exactly as today (bare lease stub) — no behavior change until one successful pass has been observed. +- Destroying a workspace removes the cached identity along with the rest of the per-host state dir; a later host with a reused name cannot inherit stale identity (host_ids are not reused). +- Cached identity never expires by time: labels are semi-static, and the truthful CRASHED/UNAUTHENTICATED host state remains the liveness signal. + +## Changes + +- `libs/mngr_imbue_cloud` provider (`providers/instance.py`): + - After each successful outer-listing pass, persist the discovered agents' identity (agent_id, name, certified_data) to `hosts//last_known_agents.json` via `atomic_write`, every pass. + - In the outer-SSH-unreachable fallback, replace the single lease-stub agent with all cached agents (name + certified_data + `"stale": true`); keep the lease stub only when no cache exists. Host state logic unchanged. + - In the empty-agents synthesis after a successful pass, do the same cache-backed substitution. + - The rich-details path needs no separate change: `_build_offline_details_from_lease` already builds `AgentDetails` from the passed refs, so cached identity flows through automatically. + - Destroy/delete cleanup is already covered by `_cleanup_local_host_state` removing the host state dir. +- Tests: + - Unit (in `providers/instance_test.py`, existing stub pattern): successful pass then unreachable pass carries prior labels and full agent set; persistence across a fresh provider instance (relaunch case); UNAUTHENTICATED behaves like CRASHED; empty-agents site; no-cache first-discovery unchanged; stale marker present on cached refs and absent on live ones. + - Integration-level test exercising a real provider round-trip of persist-then-fallback (marked per repo test-tier conventions). +- Changelog entry in `libs/mngr_imbue_cloud/changelog/` for the branch. diff --git a/vendor/mngr/blueprint/mngr-forward-http2/plan-mngr-forward-http2.md b/vendor/mngr/blueprint/mngr-forward-http2/plan-mngr-forward-http2.md new file mode 100644 index 00000000..651af39d --- /dev/null +++ b/vendor/mngr/blueprint/mngr-forward-http2/plan-mngr-forward-http2.md @@ -0,0 +1,73 @@ +# HTTP/2 for the workspace UI (TLS + h2 at the `mngr forward` proxy) + +## Overview + +- **Problem.** The whole workspace UI is served over plain HTTP/1.1 from a single origin (`agent-.localhost:`), reached through the local `mngr forward` proxy. Chromium caps HTTP/1.1 at ~6 held-open connections per origin, so once a workspace has enough long-lived streams open (one SSE per open chat tab, plus any `/service/` app's own streams), the pool is exhausted and every further request — including the plain HTTP GETs that bootstrap a new terminal/app iframe — queues indefinitely. The UI hangs even though the backend is fine. +- **Fix.** Terminate TLS and negotiate HTTP/2 at the local `mngr forward` proxy. HTTP/2 multiplexes many concurrent streams over one connection, so the per-origin ceiling stops binding — regardless of how many tabs or streams are open. This is deliberately chosen over the multiplexed-WebSocket alternative: it removes the ceiling for *everything* on the origin (including third-party `/service/` apps' own streams), and it lives entirely in `mngr forward` rather than touching `system_interface`'s streaming model. +- **The proxy is already async.** The proxy app (`libs/mngr_forward/imbue/mngr_forward/server.py`) is already FastAPI/ASGI on asyncio (`async def` handlers, `run_in_executor`, `asyncio.gather`), served today by uvicorn (itself asyncio). Swapping uvicorn→hypercorn adds **no new async runtime** — the one visible change is a single `asyncio.run(...)` line in `cli.py`. The asyncio-removal migration targeted `system_interface` (the code coding-agents edit); the proxy is infra and was always async. +- **Why it's cheap here.** The only client of this origin is the minds Electron desktop app, so browser trust needs no OS trust store or CA install: the app accepts the proxy's self-signed cert for its loopback origins directly. The external-viewer path (Cloudflare-shared services) does not use this origin at all — it terminates TLS/h2 at the Cloudflare edge — so it is unaffected. +- **Shape of the change.** Add a `--use-http2` flag to `mngr forward` (default off). When set, the proxy generates a fresh ephemeral self-signed cert at startup and serves TLS + h2 via **hypercorn**; when unset it serves plain HTTP/1.1 via hypercorn exactly as today. There is a **single** `mngr forward` subprocess for the whole app, spawned by the **Python** minds backend (`apps/minds/imbue/minds/desktop_client/forward_cli.py`, called once at `apps/minds/imbue/minds/cli/run.py:403`); it serves every `agent-.localhost` subdomain by host-header routing. minds hardcodes `--use-http2` on for that one subprocess — there is no per-workspace launch and no toggle. +- **Boundary.** Only the browser→proxy hop changes. The proxy→container hop (paramiko SSH tunnel) and the in-container `system_interface` werkzeug server stay plain HTTP/1.1 and are not touched. `SubagentView` streaming dedupe is explicitly out of scope. + +## Expected behavior + +- **The reported symptom is fixed.** With `--use-http2` on, a user can open well more than 6 streaming chat tabs in one workspace and then open a new terminal or app tab — it loads immediately. DevTools shows the workspace origin negotiated `h2` over a single connection, with no requests stuck in "Stalled". +- **No visible change otherwise.** Chat streaming, terminals, `/service/` app iframes, login, and the `/goto/` auth bridge all behave exactly as before; only the transport underneath changes. Activity badges and workspace state (the separate `/api/ws` socket) are unaffected. +- **The proxy origin flips to `https` consistently.** When h2/TLS is active, URLs the proxy and the minds client construct for the **proxy origin** become `https://`, its WebSocket URLs become `wss://`, and the proxy's `mngr_forward_session` cookie is marked `Secure`. When the flag is off, everything on the proxy stays `http`/`ws`/no-`Secure`, byte-for-byte as today. +- **The minds HTTP backend is untouched.** The minds bare-origin server (Home/Create/chrome/sidebar, its `minds_session` cookie, and the cookie-sync between session partitions) stays plain HTTP. The app deliberately runs a mixed transport — minds backend over `http://localhost:`, workspace content over `https://…:` — which is fine (an http page loading/navigating to https is not mixed-content-blocked). +- **Trust is silent and scoped.** The Electron workspace session accepts the proxy's self-signed cert with no prompt, for loopback hosts (`localhost`, `*.localhost`, `127.0.0.1`) in the `persist:workspace-content` partition only. Every real `https` origin the app touches is unchanged — the override returns "defer to Chromium" for all non-loopback hosts, and external https (Claude auth, Cloudflare shares) opens in the system browser anyway (`isExternalUrl`, `main.js:204-226`), not in that partition. +- **Loopback probes still work.** The Python `httpx` readiness/recovery probes that dial the proxy on loopback keep working over TLS by disabling cert verification for those loopback-only clients (see Changes). +- **Failure is visible, not silently degraded.** With `--use-http2` set there is no fallback to plain HTTP. If the cert can't be generated or the TLS listener can't bind, `mngr forward` exits during startup; minds' `wait_for_listening` times out and the existing `run.py:451` "forward didn't come up" path surfaces it. `mngr forward` logs an explicit line naming TLS/h2 setup as the cause so the failure is diagnosable. Mid-flight handshake failures show as normal failed workspace loads in the existing loading/recovery UI. No new error UI is added. +- **Standalone `mngr forward` is unchanged by default.** A human running `mngr forward` without the flag (including `--open-browser` into a real browser) still gets plain HTTP/1.1 and no self-signed-cert friction. `--use-http2` is opt-in for clients that can trust the cert; minds is the one that always opts in. +- **WebSockets keep working.** Terminal (ttyd), the proto-agent log stream, and the `/api/ws` state socket become `wss` over TLS. Whichever way Chromium carries a WebSocket — over the h2 connection as RFC 8441 Extended CONNECT (hypercorn advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`), or as a standard upgrade on a separate connection — hypercorn is the ASGI server, and its `websocket.receive` events always include *both* `text` and `bytes` keys with the unused one set to `None` (uvicorn omitted it). So the proxy's client→backend forwarder must select the payload by value, not key presence: a binary frame carries `text: None` alongside its bytes, and a key-presence check would forward the `None`. (WebSockets were never the constrained resource — they have a much higher browser connection limit.) +- **Cloudflare-shared services are untouched.** An outside viewer still reaches a shared service at `https://` via the Cloudflare edge → cloudflared → the service's own port; that path never involved this origin or the `mngr forward` proxy. + +## Changes + +### `mngr forward` proxy (`libs/mngr_forward`) + +- **CLI flag.** Add `--use-http2` to `forward` (`cli.py`, default off), threaded into `create_forward_app` and the serve path so the app knows whether TLS is active. Regenerate CLI docs afterward (`uv run python scripts/make_cli_docs.py`) or the root `test_cli_docs_are_up_to_date` ratchet fails. +- **Ephemeral cert.** When the flag is on, generate a fresh self-signed cert at startup with `cryptography`: one cert, SANs `localhost` + `*.localhost` (both required — the wildcard does not cover the bare label; add `127.0.0.1` too so IP-host loopback clients verify). Generated in memory and regenerated each startup. Note: stdlib `ssl.SSLContext.load_cert_chain` only accepts a filesystem path, so the PEM is written to a private `mkstemp` (0600) temp file, loaded, and unlinked immediately in the same call — it is never persisted, but does briefly touch disk (a true never-touches-disk load isn't possible with stdlib `ssl`). +- **Server swap uvicorn → hypercorn (asyncio worker).** + - Replace the `uvicorn.Server(...).run(sockets=[listen_socket])` call (`cli.py:341-342`) with `asyncio.run(hypercorn.asyncio.serve(app, config))`. + - **In-memory TLS:** subclass hypercorn `Config`, overriding `create_ssl_context()` to return an `ssl.SSLContext` built from the in-memory cert/key (ALPN `["h2", "http/1.1"]`), and `ssl_enabled` to `True`. The cert/key are never persisted (the only disk contact is the transient 0600 `mkstemp` file the stdlib `load_cert_chain` requires, unlinked immediately — see Ephemeral cert). When the flag is off, plain HTTP/1.1 (no ssl context) — behavioral parity with today. + - **Pre-bound socket handoff:** keep `_bind_listen_socket` (`cli.py:102-144`) as-is (binds, does not listen). Pass `bind=["fd://"]`, using `os.dup(listen_socket.fileno())` for the fd so hypercorn (which closes the fd it wraps on shutdown) does not double-close the socket the `finally` block also closes. `asyncio.start_server` performs the `listen()`, so no bind race. + - **Graceful shutdown:** pass `shutdown_trigger=None` so hypercorn installs its own SIGINT/SIGTERM handlers — matching uvicorn's SIGTERM→graceful behavior that minds' `terminate()` (`forward_cli.py:248`) relies on. Set `Config.graceful_timeout` ≈ 1s (matching today's `timeout_graceful_shutdown=1`). The existing `SIGHUP` handler (`cli.py:473`) is a different signal and is untouched; keep the `finally` cleanup (socket close, stream-manager stop, tunnel cleanup) after `asyncio.run` returns. + - Add a clear startup log line naming TLS/h2 setup as the failure cause if cert generation or the TLS listener fails. +- **Scheme flips (conditional on TLS active).** Make these client-facing constructions `https`/`wss` when TLS is on, `http`/`ws` when off: + - `cli.py:306-307` — login URL. + - `server.py:184`, `server.py:186` — unauthenticated-subdomain redirect (`/` and `/goto//`). + - `server.py:835` — `/goto` → `_subdomain_auth` subdomain redirect. + - **Do NOT change `server.py:696`** (`ws_backend = backend_url.replace("http://","ws://")…`) — that is the proxy→container hop and must stay `ws://`. +- **Cookie `Secure`.** Mark the proxy's own `mngr_forward_session` cookie `Secure` only when TLS is active, at both `set_cookie` sites: `_handle_subdomain_auth_bridge` (`server.py:541-547`) and `_handle_authenticate` (`server.py:771-778`). Do not touch any other cookie. +- **Backend-facing side unchanged.** The SSH `direct-tcpip` tunnel, the raw-TCP relay, and the httpx client dialing the in-container HTTP/1.1 backend are untouched. +- **Dependencies.** Add `hypercorn` and declare `cryptography` **explicitly** in `mngr_forward`'s `pyproject.toml` (do not rely on paramiko's transitive pull, so the wheel is self-contained). Drop `uvicorn` (used only in `cli.py` within this lib). + +### minds Python backend (`apps/minds`) + +- **Launch flag.** Add `--use-http2` to the argv built in `start_mngr_forward` (`forward_cli.py:619-633`) — one site, one flag, always on. (This is the single proxy for the whole app; there is no per-workspace launch.) +- **Loopback probes over TLS.** The Python `httpx` probes dial the proxy's front origin on loopback and must move to `https` with cert verification disabled for these loopback-only clients (per decision: `verify=False`; the probe targets `127.0.0.1` with a `Host: agent-.localhost` header, which cannot pass normal hostname verification anyway): + - `make_workspace_probe_client` (`agent_creator.py:96-107`) → construct the `httpx.Client` with `verify=False`. + - `probe_workspace_through_plugin` (`agent_creator.py:131-159`) → build `probe_url` as `https://127.0.0.1:/` when h2 is on. + - Confirms flow through `_await_system_interface_ready` (`workspace_recovery.py:139-159`) and `_wait_for_workspace_ready` (`agent_creator.py`). +- **Client-facing goto URL.** `_build_redirect_url` (`agent_creator.py:1996`) → `https://localhost:/goto//` when h2 is on. +- **minds HTTP backend untouched.** `minds_session` (host-only, `app.py:361-377`), the bare-origin login/authenticate flow, and the partition cookie-sync (`main.js:2472-2517`, over `http://`) are NOT changed — that server stays HTTP. + +### minds Electron desktop client (`apps/minds/electron`) + +- **Certificate override.** Register `session.fromPartition('persist:workspace-content').setCertificateVerifyProc((req, cb) => …)` (net-new; there is no existing cert hook): `cb(0)` (trust) when `req.hostname` is a loopback host (`localhost`, `*.localhost`, `127.0.0.1`), `cb(-3)` (defer to Chromium's default result) otherwise. The single shared `CONTENT_PARTITION` (`main.js:82`) covers all workspaces; the default session is not modified. +- **Proxy-origin scheme + cookie.** When h2 is on, build the proxy origin as `https://localhost:` in `handleMngrForwardStarted` (`main.js:2980-3007`) and mark the pre-set `mngr_forward_session` cookie `secure: true` (set it via a matching `https://` url). `workspaceUrlForAgent` (`main.js:231-241`) then builds `https` `/goto/` URLs automatically off `mngrForwardBaseUrl`. `backendBaseUrl` (the minds HTTP backend, `main.js:2763`) stays `http://`. + +### Testing + +- Unit-test the cert generation (SANs present) and the `Config.create_ssl_context()` override returns a context with the expected ALPN. +- Add TLS-path coverage for the scheme flips: existing `server_test.py:157` / `:285` assert `http://` on the flag-off default and stay valid; add flag-on variants asserting `https://` / `wss://` and the `Secure` cookie attribute. +- Cover the fd handoff (no double-close) and that `--use-http2` off is byte-for-byte the current behavior. + +## Out of scope + +- Any change to `system_interface` (its werkzeug server stays HTTP/1.1 behind the tunnel) or the minds HTTP backend origin. +- The multiplexed-WebSocket transcript fix and the `SubagentView` streaming-dedupe cleanup. +- HTTP/3 / QUIC, cert persistence/rotation, and making real (non-Electron) browsers trust the cert. +- A minds-side toggle / runtime HTTP fallback — `--use-http2` is hardcoded on; failure is surfaced, not silently degraded. +- Any change to the Cloudflare sharing path. diff --git a/vendor/mngr/dev/CHANGELOG.md b/vendor/mngr/dev/CHANGELOG.md index ef90b11a..737805f2 100644 --- a/vendor/mngr/dev/CHANGELOG.md +++ b/vendor/mngr/dev/CHANGELOG.md @@ -4,6 +4,29 @@ A concise, human-friendly summary of changes for repo-level dev tooling: CI work For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDGED_CHANGELOG.md). +## 2026-07-13 + +### Added + +- Added: `blueprint/mngr-forward-http2/` — implementation plan for terminating TLS and negotiating HTTP/2 at the `mngr forward` proxy so the workspace UI is no longer capped by Chromium's per-origin HTTP/1.1 connection limit. +- Added: `blueprint/imbue-cloud-sticky-agent-labels/plan-imbue-cloud-sticky-agent-labels.md` — design plan for the imbue_cloud "husk" fix (persisting and re-attaching last-known agent identity so a transiently-unreachable leased workspace keeps its labels instead of collapsing to a label-less stub). Implementation lands in `libs/mngr_imbue_cloud`. +- Added: `blueprint/forward-services-cache/` — spec for the `mngr_forward` fast-first-load fix (persist the resolver's per-agent service map to disk and seed from it at startup so a restored remote-mind window resolves at ~3s instead of the measured ~50s cold-stream wait, with the live `mngr event` stream as the correction path). Implementation ships on the same branch under `libs/mngr_forward/`. + +### Changed + +- Changed: The minds e2e snapshot build (`scripts/snapshot_minds_e2e_state.py`) now pins the create default to runc via `MINDS_DOCKER_RUNTIME_DEFAULT=RUNC`. The Modal snapshot sandbox has no gVisor, and the per-create runtime feature otherwise defaults the Linux create form to runsc and stacks the `docker_runsc` template, so the build's workspace creation failed with "unknown or invalid runtime name: runsc". The previous `MNGR__PROVIDERS__DOCKER__DOCKER_RUNTIME=runc` override could not fix this (an explicitly stacked template's docker_runtime outranks a provider-config env var in mngr's create-settings precedence) and was removed as redundant. + +## 2026-07-11 + +### Added + +- Added: `scripts/rename_template_repo.py` — migration tool that renames the forever-claude-template repo to a new name. All case forms (kebab, snake, SNAKE_UPPER, Title, Pascal) are derived from `--new-name`; `--new-abbreviation` sets the shorthand that replaces `fct`/`FCT` (context-sensitive across snake, kebab, and CamelCase identifiers). Dry-run by default; `--apply` edits in place idempotently, `--check` verifies no live references remain (including CamelCase-embedded forms), `--show-diff` prints unified diffs. Historical records (changelog entries, `specs/`, `blueprint/`), vendored trees, and lockfiles are reported but never rewritten. +- Added: `scripts/migrate_state_fct_to_default_workspace_template.sh` — developer-local state migration for the template rename (stale `.external_worktrees` removal, template checkout dir rename, git remote URLs, `apps/minds/.env` var rename, `__pycache__` sweep). Dry-run flag, idempotent; reports anything it is unsure about instead of touching it. + +### Changed + +- Changed: Repo-wide rename of forever-claude-template to default-workspace-template (justfile, CI workflows, scripts, Claude skills), applied mechanically by the new rename tool. The GitHub repo rename itself happens out of band. + ## 2026-07-10 ### Added diff --git a/vendor/mngr/dev/UNABRIDGED_CHANGELOG.md b/vendor/mngr/dev/UNABRIDGED_CHANGELOG.md index ea2ce93b..28ec45e3 100644 --- a/vendor/mngr/dev/UNABRIDGED_CHANGELOG.md +++ b/vendor/mngr/dev/UNABRIDGED_CHANGELOG.md @@ -4,6 +4,26 @@ Full, unedited changelog entries consolidated nightly from individual files in ` For a concise summary, see [CHANGELOG.md](CHANGELOG.md). +## 2026-07-13 + +Add the `blueprint/mngr-forward-http2/` implementation plan for terminating TLS and negotiating HTTP/2 at the `mngr forward` proxy so the workspace UI is no longer capped by Chromium's per-origin HTTP/1.1 connection limit. + +Added `blueprint/imbue-cloud-sticky-agent-labels/plan-imbue-cloud-sticky-agent-labels.md`, the design plan for the imbue_cloud "husk" fix (persisting and re-attaching last-known agent identity so a transiently-unreachable leased workspace keeps its labels instead of collapsing to a label-less stub). The implementation lands in `libs/mngr_imbue_cloud`. + +The minds e2e snapshot build (`scripts/snapshot_minds_e2e_state.py`) now pins the create default to runc via `MINDS_DOCKER_RUNTIME_DEFAULT=RUNC`. The Modal snapshot sandbox has no gVisor, and the per-create runtime feature otherwise defaults the Linux create form to runsc and stacks the `docker_runsc` template, so the build's workspace creation failed with "unknown or invalid runtime name: runsc". The previous `MNGR__PROVIDERS__DOCKER__DOCKER_RUNTIME=runc` override could not fix this -- an explicitly stacked template's docker_runtime outranks a provider-config env var in mngr's create settings precedence -- so it was removed as redundant. Setting the minds default keeps `docker_runsc` from being stacked in the first place. + +Added a blueprint spec (`blueprint/forward-services-cache/`) for a fast first-load fix in the `mngr_forward` plugin: persist the resolver's per-agent service map to disk and seed from it at startup so a restored remote-mind window resolves at ~3s instead of the measured ~50s cold-stream wait, with the live `mngr event` stream as the correction path. The root-cause investigation and live latency measurements (cold single stream ~10s; contention-inflated tail ~50s; fixed/stable service ports) are folded into the spec. This `dev` entry covers the spec artifact only; the implementation ships on the same branch under `libs/mngr_forward/` (see that project's changelog). + +## 2026-07-11 + +Rename the forever-claude-template repo to default-workspace-template across the monorepo (justfile, CI workflows, scripts, and Claude skills), applied mechanically by the new rename tool below. The GitHub repo rename itself happens out of band and must precede merging this PR. + +Add `scripts/rename_template_repo.py`, a migration tool that renames the forever-claude-template repo to a new name given on the command line. All case forms (kebab, snake, SNAKE_UPPER, Title, Pascal) are derived from the `--new-name` input; `--new-abbreviation` sets the shorthand that replaces `fct`/`FCT`, applied context-sensitively (snake_case next to `_` and for bare identifiers, kebab-case next to `-` or `:` when followed by a tag, CamelCase inside identifiers like `FctTemplateRef`). A cleanup pass collapses word duplication the rename introduces (e.g. `DEFAULT_DEFAULT_...` and "the WORKSPACE_TEMPLATE template") and fixes a/an article agreement. + +Dry-run by default; `--apply` edits in place and is idempotent, `--check` verifies no live references remain (including CamelCase-embedded forms), `--show-diff` prints unified diffs. Renames whose target already exists (an old-name file reintroduced by merging a pre-rename branch) drop the old file when contents match and warn otherwise; symlink targets embedding the old name are rewritten. Historical records (changelog entries, consolidated CHANGELOG files, `specs/`, `blueprint/`), vendored trees, and lockfiles (`uv.lock`, `pnpm-lock.yaml`, `package-lock.json`) are reported but never rewritten. + +Add `scripts/migrate_state_fct_to_default_workspace_template.sh`: developer-local state migration (stale `.external_worktrees` removal, template checkout dir rename, git remote URLs, `apps/minds/.env` var rename, `__pycache__` sweep). Dry-run flag, idempotent, reports anything it is unsure about instead of touching it. + ## 2026-07-10 Added `specs/discovery-log-cleanup.md`: a plan for cleaning up discovery logging and provider treatment in the Minds app. It covers once-per-process suppression of repeated provider-level discovery-error warnings in the three stream consumers, startup snapshots from `mngr observe --discovery-only` for providers skipped as unauthorized/unavailable/empty, always writing the `[providers.aws-]` blocks regardless of AWS credentials (preserving `is_enabled`), and bouncing the observe child when the bootstrap's settings write changes the provider set. diff --git a/vendor/mngr/dev/changelog/gabriel-archetypal-mouse.md b/vendor/mngr/dev/changelog/gabriel-archetypal-mouse.md new file mode 100644 index 00000000..0ef57392 --- /dev/null +++ b/vendor/mngr/dev/changelog/gabriel-archetypal-mouse.md @@ -0,0 +1 @@ +Added a blueprint plan (`blueprint/electron-log-and-crash-page/`) for persisting Electron main-process logs to a new rotated/gzipped `electron.log` (uploaded with bug reports alongside a newly-rotated `minds.log`) and for recovering from renderer death across all three per-window views: a Chrome-style crash page for the workspace content view, a miniaturized in-titlebar error strip with a Reload button for the chrome view, and a silent warm reload for the overlay view. The plan also closes an observability gap by reporting abnormal renderer deaths (`crashed`/`oom`, labeled by view) to Sentry while deliberately not reporting sleep/external kills (`killed`). diff --git a/vendor/mngr/dev/changelog/mngr-add-dockview-profiles.md b/vendor/mngr/dev/changelog/mngr-add-dockview-profiles.md new file mode 100644 index 00000000..448b21bf --- /dev/null +++ b/vendor/mngr/dev/changelog/mngr-add-dockview-profiles.md @@ -0,0 +1,3 @@ +Add the blueprint for named dockview layouts in the default workspace template (`blueprint/dockview-named-layouts/`). + +The implementation itself lands in the `default-workspace-template` repo (same branch name there): named `desktop`/`mobile` layouts with per-client selection, "+"-menu save/load/delete dialogs, live cross-client sync, a client-activity event log, and layout-targeted `layout.py` ops with new `context` and `load` subcommands. diff --git a/vendor/mngr/dev/changelog/mngr-fable.md b/vendor/mngr/dev/changelog/mngr-fable.md new file mode 100644 index 00000000..d9a05fdd --- /dev/null +++ b/vendor/mngr/dev/changelog/mngr-fable.md @@ -0,0 +1,3 @@ +- Bump the pinned Claude Code CLI version from 2.1.160 to 2.1.207 in CI workflows (`release-tests.yml`, `tmr-setup` action) and the minds e2e snapshot script, matching the new workspace pin that supports Claude Fable 5. + +- Add `claude-fable-5` with inline pricing ($10 / $50 per 1M input / output tokens, cache write 1.25e-5, cache read 1e-6) to the repo-root local-dev LiteLLM proxy config (`litellm_proxy/config.yaml`), kept in sync with `apps/modal_litellm/app.py` by a drift test. diff --git a/vendor/mngr/dev/changelog/mngr-fct-rename.md b/vendor/mngr/dev/changelog/mngr-fct-rename.md deleted file mode 100644 index dca8edfa..00000000 --- a/vendor/mngr/dev/changelog/mngr-fct-rename.md +++ /dev/null @@ -1,7 +0,0 @@ -Rename the forever-claude-template repo to default-workspace-template across the monorepo (justfile, CI workflows, scripts, and Claude skills), applied mechanically by the new rename tool below. The GitHub repo rename itself happens out of band and must precede merging this PR. - -Add `scripts/rename_template_repo.py`, a migration tool that renames the forever-claude-template repo to a new name given on the command line. All case forms (kebab, snake, SNAKE_UPPER, Title, Pascal) are derived from the `--new-name` input; `--new-abbreviation` sets the shorthand that replaces `fct`/`FCT`, applied context-sensitively (snake_case next to `_` and for bare identifiers, kebab-case next to `-` or `:` when followed by a tag, CamelCase inside identifiers like `FctTemplateRef`). A cleanup pass collapses word duplication the rename introduces (e.g. `DEFAULT_DEFAULT_...` and "the WORKSPACE_TEMPLATE template") and fixes a/an article agreement. - -Dry-run by default; `--apply` edits in place and is idempotent, `--check` verifies no live references remain (including CamelCase-embedded forms), `--show-diff` prints unified diffs. Renames whose target already exists (an old-name file reintroduced by merging a pre-rename branch) drop the old file when contents match and warn otherwise; symlink targets embedding the old name are rewritten. Historical records (changelog entries, consolidated CHANGELOG files, `specs/`, `blueprint/`), vendored trees, and lockfiles (`uv.lock`, `pnpm-lock.yaml`, `package-lock.json`) are reported but never rewritten. - -Add `scripts/migrate_state_fct_to_default_workspace_template.sh`: developer-local state migration (stale `.external_worktrees` removal, template checkout dir rename, git remote URLs, `apps/minds/.env` var rename, `__pycache__` sweep). Dry-run flag, idempotent, reports anything it is unsure about instead of touching it. diff --git a/vendor/mngr/dev/changelog/mngr-mngr-msg.md b/vendor/mngr/dev/changelog/mngr-mngr-msg.md new file mode 100644 index 00000000..7a46295f --- /dev/null +++ b/vendor/mngr/dev/changelog/mngr-mngr-msg.md @@ -0,0 +1 @@ +Corrected `specs/common-transcript-standard/spec.md`: marked it implemented (Tier 1 + Tier 2 landed in `90ef7a979`, 2026-06-15) and rewrote the Compatibility section, which wrongly claimed common transcripts are "continuously re-derived from the raw stream." They are not -- `convert()` dedups by `event_id` and only appends, so assistant lines from a pre-`parts[]` emitter are never healed and render `(no content)` under the `parts[]`-only reader. The gap is keyed on emitter version (some old-emitter agents are still active), not line age, and is accepted; the flat-field reader fallback is a back-compat shim for old-emitter lines, not a fix for a broken emitter. diff --git a/vendor/mngr/libs/imbue_common/CHANGELOG.md b/vendor/mngr/libs/imbue_common/CHANGELOG.md index 2dafccd6..a07d890f 100644 --- a/vendor/mngr/libs/imbue_common/CHANGELOG.md +++ b/vendor/mngr/libs/imbue_common/CHANGELOG.md @@ -9,6 +9,7 @@ For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDG ### Added - Added: Shared Sentry error-reporting library `imbue.imbue_common.sentry`, packaging the generic pieces previously duplicated in the minds backend: loguru-to-Sentry event/breadcrumb handlers, an unsigned-S3 attachment uploader, a per-exception rate limiter, an oversized-event (HTTP 413) transport, a `before_send` chain (automatic-reporting consent gate and interrupt/clean-shutdown filtering), manual bug-report submission, and a parameterized `setup_sentry`. Callers supply concrete `dsn` / `environment_name` / `s3_attachment_bucket` (plus service name, integrations, and log-attachment groups); no Sentry project/environment/bucket knowledge lives in the library. Adds `sentry-sdk`, `boto3`, and `traceback-with-variables` as `imbue-common` dependencies. +- Added: `setup_sentry` accepts an `ignored_loggers` argument — glob patterns for stdlib logger names whose records must never become Sentry events or breadcrumbs. Sentry's default logging integration patches `logging.Logger.callHandlers` at the class level and captures a logger's ERROR records as events even when the logger has `propagate=False`, so callers that already route a noisy third-party logger's output elsewhere (e.g. into loguru) can now drop the raw records instead of flooding on already-handled noise. - Added: Shared `PREVENT_ASYNC_AWAIT` ratchet rule (`common_ratchets.py`) and `check_async_await` wrapper (`standard_ratchet_checks.py`) that power a per-project `test_prevent_async_await` ratchet freezing `async def` / `await` usage across the monorepo. - Added: `LowerCaseStrEnum` in `imbue.imbue_common.enums` — lowercase sibling of `UpperCaseStrEnum`, for enums whose values are an externally visible already-lowercase wire format. @@ -16,6 +17,10 @@ For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDG - Changed: `find_bash_scripts_without_strict_mode` (the helper behind the repo-wide bash strict-mode ratchet) now skips `*.sh` files under `.minds/template/`. Those are declarative secret-schema templates sourced by the deploy tooling, not runnable scripts, so `set -euo pipefail` is meaningless for them. +### Fixed + +- Fixed: `PREVENT_TRAILING_COMMENTS` ratchet no longer misfires on `PR #NNNN` references inside comment or docstring prose. The unanchored pattern treated the `#` of a PR number as a trailing comment; a negative lookbehind now exempts a `#` immediately preceded by `PR `, alongside the existing hex-color and `ty: ignore` exemptions. + ## [v0.1.20] - 2026-06-13 ### Changed diff --git a/vendor/mngr/libs/imbue_common/UNABRIDGED_CHANGELOG.md b/vendor/mngr/libs/imbue_common/UNABRIDGED_CHANGELOG.md index 9652cac3..8c5d9ec1 100644 --- a/vendor/mngr/libs/imbue_common/UNABRIDGED_CHANGELOG.md +++ b/vendor/mngr/libs/imbue_common/UNABRIDGED_CHANGELOG.md @@ -4,6 +4,12 @@ Full, unedited changelog entries consolidated nightly from individual files in ` For a concise summary, see [CHANGELOG.md](CHANGELOG.md). +## 2026-07-13 + +The trailing-comments ratchet (PREVENT_TRAILING_COMMENTS) no longer misfires on `PR #NNNN` references inside comment or docstring prose. The unanchored pattern treated the `#` of a PR number as a trailing comment (even on comment-only lines, since a match can start mid-line); a negative lookbehind now exempts a `#` immediately preceded by `PR `, alongside the existing hex-color and `ty: ignore` exemptions. + +`setup_sentry` now accepts an `ignored_loggers` argument: glob patterns for stdlib logger names whose records must never become Sentry events or breadcrumbs. This is needed because Sentry's default logging integration patches `logging.Logger.callHandlers` at the class level, so it captures a logger's ERROR records as events even when that logger has `propagate=False`. Callers that already route a noisy third-party logger's output elsewhere (e.g. into loguru) can now pass those logger names so Sentry drops the raw records instead of flooding on already-handled noise. + ## 2026-07-09 - Added: `LowerCaseStrEnum` in `imbue.imbue_common.enums` -- the lowercase sibling of `UpperCaseStrEnum`, for enums whose values are an externally visible, already-lowercase wire format (first used by the pool bake / destroy outcome statuses in `mngr_imbue_cloud`). diff --git a/vendor/mngr/libs/imbue_common/changelog/mngr-inline-fn-ratchet-doublecount.md b/vendor/mngr/libs/imbue_common/changelog/mngr-inline-fn-ratchet-doublecount.md new file mode 100644 index 00000000..a273047d --- /dev/null +++ b/vendor/mngr/libs/imbue_common/changelog/mngr-inline-fn-ratchet-doublecount.md @@ -0,0 +1 @@ +Fixed the inline-function ratchet (`find_inline_functions`) double-counting a function nested two or more levels deep. It walked every `FunctionDef` in a file, including nested ones, and descended into all of each one's descendants, so a function with two enclosing functions was emitted once per ancestor. The recorded count therefore overstated the real number of inline functions. Nested defs are now collected keyed by source position and counted once. Only projects that actually nest functions two deep are affected; across the whole monorepo the sole recorded count that changes is `apps/minds` (from 9 to 7 before any code change). diff --git a/vendor/mngr/libs/imbue_common/imbue/imbue_common/ratchet_testing/common_ratchets.py b/vendor/mngr/libs/imbue_common/imbue/imbue_common/ratchet_testing/common_ratchets.py index b6089e4a..23c5ab02 100644 --- a/vendor/mngr/libs/imbue_common/imbue/imbue_common/ratchet_testing/common_ratchets.py +++ b/vendor/mngr/libs/imbue_common/imbue/imbue_common/ratchet_testing/common_ratchets.py @@ -236,9 +236,9 @@ def check_ratchet_rule_all_files( rule_name="trailing comments", rule_description=( "Comments should be on their own line, not trailing after code. Trailing comments make code harder to read. " - "`# ty: ignore[code]` is exempt." + "`# ty: ignore[code]` is exempt, as are hex colors and `PR #NNNN` references inside prose." ), - pattern_string=r"[^\s#].*[ \t]#(?![0-9a-fA-F]{3,6}[;\s])(?!\s*ty:\s*ignore\[)", + pattern_string=r"[^\s#].*[ \t](? None: + """Tell the default ``LoggingIntegration`` to drop records from the given stdlib loggers. + + ``ignore_logger`` mutates a module-level registry the integration reads live (via ``fnmatch``), + so each name may be a glob and the effect applies to both events and breadcrumbs. Needed because + the integration patches ``logging.Logger.callHandlers`` at the class level and thus captures a + logger's ERROR records as events even when that logger has ``propagate=False`` -- which would + otherwise flood Sentry with handled third-party noise (e.g. paramiko SSH banner errors). + """ + for ignored_logger in ignored_loggers: + ignore_logger(ignored_logger) + + def fixup_release_id(release_id: str) -> str: """ For pre-release release candidate versions, Sentry requires the release ID to be in the semver format. @@ -537,6 +551,13 @@ def setup_sentry( # ``is_log_inclusion_enabled``. s3_attachment_bucket: str | None = None, extra_tags: Mapping[str, str] | None = None, + # Glob patterns (matched via ``fnmatch``) for stdlib logger names whose records must never + # become Sentry events or breadcrumbs. The default ``LoggingIntegration`` patches + # ``logging.Logger.callHandlers`` at the class level, so it captures ERROR-level stdlib records + # as events even for loggers whose ``propagate`` is disabled. Callers that already route a noisy + # third-party logger's output elsewhere (e.g. mngr redirects paramiko/pyinfra into loguru) pass + # those logger names here so Sentry drops the raw records instead of flooding on handled noise. + ignored_loggers: Sequence[str] = (), ) -> None: """Sets up the main Sentry instance for this process. @@ -613,6 +634,8 @@ def setup_sentry( ) logger.info("Sentry initialized") + _register_ignored_loggers(ignored_loggers) + # The S3 attachment uploader is initialized whenever a bucket is configured. Whether # logs/tracebacks are actually collected and uploaded is decided live per-event by # ``is_log_inclusion_enabled`` (in ``add_extra_info_hook``); with no bucket, nothing is uploaded. diff --git a/vendor/mngr/libs/imbue_common/imbue/imbue_common/sentry/core_test.py b/vendor/mngr/libs/imbue_common/imbue/imbue_common/sentry/core_test.py index 0161b0a5..d1455c51 100644 --- a/vendor/mngr/libs/imbue_common/imbue/imbue_common/sentry/core_test.py +++ b/vendor/mngr/libs/imbue_common/imbue/imbue_common/sentry/core_test.py @@ -1,4 +1,6 @@ +import logging import sys +from collections.abc import Iterator from pathlib import Path from typing import cast @@ -8,6 +10,8 @@ from sentry_sdk import Client from sentry_sdk import isolation_scope from sentry_sdk.envelope import Envelope +from sentry_sdk.integrations.logging import EventHandler +from sentry_sdk.integrations.logging import unignore_logger from sentry_sdk.transport import Transport from sentry_sdk.types import Event from sentry_sdk.types import Hint @@ -17,6 +21,7 @@ from imbue.imbue_common.sentry.core import _before_send_wrapper from imbue.imbue_common.sentry.core import _drop_interrupt_events from imbue.imbue_common.sentry.core import _make_automatic_reporting_gate +from imbue.imbue_common.sentry.core import _register_ignored_loggers from imbue.imbue_common.sentry.core import add_extra_info_hook from imbue.imbue_common.sentry.core import fixup_release_id from imbue.imbue_common.sentry.core import register_attachments_uploader @@ -258,6 +263,31 @@ def before_send(event: Event, hint: Hint) -> Event | None: assert extra["bug_report"]["description"] == "boom" +@pytest.fixture +def _cleanup_ignored_loggers() -> Iterator[list[str]]: + # ``ignore_logger`` mutates a process-global sentry registry, so any patterns a test registers + # must be reverted afterward to avoid leaking into other tests. + registered: list[str] = [] + yield registered + for pattern in registered: + unignore_logger(pattern) + + +def test_register_ignored_loggers_makes_matching_loggers_ignored(_cleanup_ignored_loggers: list[str]) -> None: + # The default LoggingIntegration captures ERROR-level stdlib records as Sentry events even for + # loggers with propagate=False (it patches Logger.callHandlers at the class level). Registering a + # glob pattern must make its EventHandler drop matching records -- both the exact name and any + # child logger -- while leaving unrelated loggers alone. + patterns = ["paramiko", "paramiko.*"] + _cleanup_ignored_loggers.extend(patterns) + _register_ignored_loggers(patterns) + + handler = EventHandler(level=logging.ERROR) + assert handler._can_record(logging.makeLogRecord({"name": "paramiko"})) is False + assert handler._can_record(logging.makeLogRecord({"name": "paramiko.transport"})) is False + assert handler._can_record(logging.makeLogRecord({"name": "imbue.minds"})) is True + + def test_submit_manual_bug_report_returns_none_when_sentry_inactive() -> None: # With no active Sentry client (the default in tests), the submit is a no-op that returns None # (no event id) rather than raising. diff --git a/vendor/mngr/libs/mngr/CHANGELOG.md b/vendor/mngr/libs/mngr/CHANGELOG.md index 14a64d80..97f95370 100644 --- a/vendor/mngr/libs/mngr/CHANGELOG.md +++ b/vendor/mngr/libs/mngr/CHANGELOG.md @@ -8,6 +8,8 @@ For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDG ### Added +- Added: `SENTRY_IGNORED_STDLIB_LOGGER_PATTERNS` — set of noisy third-party stdlib logger names (paramiko, pyinfra) that mngr already redirects into loguru. Sentry-reporting processes pass these to `setup_sentry` so Sentry does not independently capture the raw stdlib records as events; without this, paramiko transport-thread ERROR logs on handled SSH connection failures (e.g. "Error reading SSH protocol banner" when a reverse-tunnel target goes offline) flooded Sentry with tens of thousands of unactionable, un-rate-limited events. +- Added: `get_container_loopback_ssh_port` provider-interface seam (default `None`). Providers whose topology splits "publish" from "connect" — e.g. an imbue_cloud lima slice, where the container's sshd is published in the VM on a fixed port but reached from outside via a box-forwarded port — report the port at which the container is reachable from the outer host's own loopback, so an outer-host-resident service (the VPS-resident latchkey gateway) can reverse-tunnel into the container on the correct port. - Added: `DiscoveryErrorLogSuppressor` — shared per-process deduplicator for provider-level discovery-error log lines. A provider stuck on the same failure (e.g. missing credentials) logs once with a suppression note, a different error logs immediately, and a clean snapshot logs an info-level recovery line and re-arms suppression. Host- and agent-attributed discovery errors are never suppressed. - Added: `mngr observe --discovery-only` emits a startup `DISCOVERY_PROVIDER` snapshot for each provider skipped at stream startup (error snapshot for unavailable/unauthorized providers, clean zero-agent snapshot for known-empty ones like Modal with no per-user environment), each carrying the provider's config. `get_all_provider_instances_and_skipped` reports the skipped provider constructions alongside the constructed instances. - Added: Per-provider discovery. Each provider is discovered independently and emits its own `ProviderDiscoverySnapshotEvent`, so a single stuck provider no longer blocks discovery of the others. `mngr observe --discovery-only` runs one decoupled poll loop per provider, and `mngr list` writes one per-provider snapshot per provider. New per-provider `[providers.]` config: `discovery_poll_interval_seconds` (default 30), `discovery_warn_seconds` (default 20), `discovery_error_timeout_seconds` (default 120), and per-host / per-agent `host_discovery_timeout_seconds` / `agent_discovery_timeout_seconds` (default 30). A hung provider is bounded without killing threads (warn -> per-provider `DiscoveryError` after the error timeout; the abandoned read's late result is accepted on a later poll). Added a shared span-aware `DiscoveryStateAggregator` that reconciles a snapshot with concurrent state changes; every discovery consumer now goes through it. diff --git a/vendor/mngr/libs/mngr/UNABRIDGED_CHANGELOG.md b/vendor/mngr/libs/mngr/UNABRIDGED_CHANGELOG.md index 221f69e7..74386a06 100644 --- a/vendor/mngr/libs/mngr/UNABRIDGED_CHANGELOG.md +++ b/vendor/mngr/libs/mngr/UNABRIDGED_CHANGELOG.md @@ -4,6 +4,24 @@ Full, unedited changelog entries consolidated nightly from individual files in ` For a concise summary, see [CHANGELOG.md](CHANGELOG.md). +## 2026-07-13 + +Regenerate the `mngr forward` command docs to document its new `--use-http2` flag, which terminates TLS and negotiates HTTP/2 (via ALPN) for the workspace origin instead of serving plain HTTP/1.1. + +Documentation only: the `HostState.UNKNOWN` comment in `primitives.py` now covers both of the state's producers -- the existing provider-level case (AgentObserver emitting UNKNOWN for hosts of a provider that errored during discovery) and the new per-host case (a provider that is itself reachable but could not observe a specific host, e.g. imbue_cloud's lease-only fallback when a leased host's outer SSH is unreachable, which now surfaces UNKNOWN instead of CRASHED). + +Added `SENTRY_IGNORED_STDLIB_LOGGER_PATTERNS`, the set of noisy third-party stdlib loggers (paramiko, pyinfra) that mngr already redirects into loguru. Sentry-reporting processes pass these to `setup_sentry` so Sentry does not independently capture the raw stdlib records as events. Without this, paramiko's transport thread logging handled SSH connection failures (e.g. "Error reading SSH protocol banner" when a reverse-tunnel target goes offline and the health check retries) flooded Sentry with tens of thousands of unactionable, un-rate-limited error events. + +Added a `get_container_loopback_ssh_port` seam to the provider interface (default `None`). It lets a provider whose topology splits "publish" from "connect" -- e.g. an imbue_cloud lima slice, where the container's sshd is published in the VM on a fixed port but reached from outside via a box-forwarded port -- report the port at which the container is reachable from the outer host's own loopback, so a service running on the outer host (the VPS-resident latchkey gateway) can reverse-tunnel into the container on the correct port. + +Regenerated the `mngr latchkey forward` CLI help doc to note that the supervisor now health-checks and respawns the shared `latchkey gateway` subprocess if it dies mid-session. + +## 2026-07-11 + +The forever-claude-template repo is being renamed to default-workspace-template (with the `fct`/`FCT` shorthand expanded to `default_workspace_template`/`DEFAULT_WORKSPACE_TEMPLATE` forms). + +References in this project (comments, identifiers, docs) are mechanically updated by `scripts/rename_template_repo.py`. + ## 2026-07-10 Added `DiscoveryErrorLogSuppressor`, a shared per-process deduplicator for provider-level discovery-error log lines: a provider stuck on the same failure (e.g. missing credentials) is logged once (with a note that repeats are suppressed), a different error logs immediately, and a clean snapshot from the provider logs an info-level recovery line and re-arms suppression. Host- and agent-attributed discovery errors are never suppressed. diff --git a/vendor/mngr/libs/mngr/changelog/mngr-add-dockview-profiles.md b/vendor/mngr/libs/mngr/changelog/mngr-add-dockview-profiles.md new file mode 100644 index 00000000..f7654a22 --- /dev/null +++ b/vendor/mngr/libs/mngr/changelog/mngr-add-dockview-profiles.md @@ -0,0 +1,5 @@ +Fix the Docker provider's create-time host-name collision check matching containers from other mngr environments. + +`_find_container_by_name` filtered only by the `host-name` and `provider` labels, which carry no environment discriminator -- two environments differing only in `MNGR_PREFIX` (e.g. two minds envs) label their containers identically, so creating a host whose name was in use in a *different* environment failed with a misleading "container already exists" error naming a container that does not exist. The lookup now also requires the container's actual name to match `` (the same uniqueness scope Docker itself enforces and the same prefix filter discovery applies), so collisions are detected only within the current environment. The same lookup backs host resolution by name, so `mngr` commands addressed by host name also no longer resolve to (and act on) another environment's same-named container. + +Test-only: give `test_extras_claude_plugin_subcommand` timeout headroom (it shells out to the `claude` Node CLI, whose startup can exceed the global 10s pytest-timeout on a contended CI sandbox). diff --git a/vendor/mngr/libs/mngr/changelog/mngr-fable.md b/vendor/mngr/libs/mngr/changelog/mngr-fable.md new file mode 100644 index 00000000..ecfbad62 --- /dev/null +++ b/vendor/mngr/libs/mngr/changelog/mngr-fable.md @@ -0,0 +1 @@ +Bump the release-sandbox Dockerfile's pinned Claude Code version from 2.1.160 to 2.1.207, which supports the Claude Fable 5 model. Must land together with the matching `[agent_types.claude].version` bump in default-workspace-template. diff --git a/vendor/mngr/libs/mngr/changelog/mngr-fct-rename.md b/vendor/mngr/libs/mngr/changelog/mngr-fct-rename.md deleted file mode 100644 index f5c39e78..00000000 --- a/vendor/mngr/libs/mngr/changelog/mngr-fct-rename.md +++ /dev/null @@ -1,3 +0,0 @@ -The forever-claude-template repo is being renamed to default-workspace-template (with the `fct`/`FCT` shorthand expanded to `default_workspace_template`/`DEFAULT_WORKSPACE_TEMPLATE` forms). - -References in this project (comments, identifiers, docs) are mechanically updated by `scripts/rename_template_repo.py`. diff --git a/vendor/mngr/libs/mngr/changelog/mngr-fix-flaky-ci-tests.md b/vendor/mngr/libs/mngr/changelog/mngr-fix-flaky-ci-tests.md new file mode 100644 index 00000000..553c9b2e --- /dev/null +++ b/vendor/mngr/libs/mngr/changelog/mngr-fix-flaky-ci-tests.md @@ -0,0 +1,7 @@ +Fixed the root causes of two flaky CI tests (issue #2456): + +- Remote host connections now retry (once, immediately) when the SSH handshake fails with paramiko's "Error reading SSH protocol banner" -- the signature of a freshly booted host (e.g. a new Modal sandbox or VPS) that accepts TCP before sshd is ready. Previously the first failed connect was fatal, surfacing to users as a spurious "Create agent failed" and making `test_snapshot_create_then_list_on_modal` flaky. Other connect failures (refused, unreachable, auth, host key) are still not retried, so genuinely-down hosts fail fast, and the two attempts bound the worst case (a host that accepts TCP but never speaks SSH) at about 30 seconds. + +- Extras CLI tests no longer spawn the real `claude` CLI: a shared stub-`claude` fixture replaces the Node process (whose startup on a contended CI sandbox tripped the global 10s offload pytest-timeout in `test_extras_claude_plugin_subcommand` and `test_extras_no_args_shows_status`) while still exercising the real shell-out and JSON-parsing plumbing. The real `claude` probe stays covered by a new acceptance test with a Node-startup-sized timeout. + +- Marked `test_worker_hubs_do_not_accumulate_across_polls` as known-flaky with a 30s per-test timeout: its 30 executor spinups and full-heap gevent-hub probes take ~5s even on an idle machine, so on a contended runner it could cross the suite-wide 10s pytest-timeout. diff --git a/vendor/mngr/libs/mngr/changelog/mngr-mngr-msg.md b/vendor/mngr/libs/mngr/changelog/mngr-mngr-msg.md new file mode 100644 index 00000000..5b139495 --- /dev/null +++ b/vendor/mngr/libs/mngr/changelog/mngr-mngr-msg.md @@ -0,0 +1 @@ +Fixed `mngr transcript` (human format) rendering assistant messages as `(no content)` when the event has no ordered `parts[]` list. The current common-transcript emitter always fills `parts[]`, but agents running an emitter version predating it (before 2026-06-15) emit only the flat `text`/`tool_calls`, so their assistant turns -- including ones with real text -- collapsed to `(no content)`. The formatter now falls back to the flat fields when `parts[]` is empty (a back-compat shim for those older agents; see MIND-113). diff --git a/vendor/mngr/libs/mngr/changelog/mngr-revive-done-agents-on-message.md b/vendor/mngr/libs/mngr/changelog/mngr-revive-done-agents-on-message.md new file mode 100644 index 00000000..14352d62 --- /dev/null +++ b/vendor/mngr/libs/mngr/changelog/mngr-revive-done-agents-on-message.md @@ -0,0 +1,9 @@ +Fixed messaging an agent whose process had exited but whose tmux session was still alive (e.g. after a ctrl-c, a crash, or an out-of-memory kill of just the agent process). Previously the message was typed into the leftover shell and lost; now the agent is restarted and the message delivered. + +This had two parts: + +- Lifecycle detection no longer mis-reports such an agent as `REPLACED`. A known-type agent whose pane foreground has dropped back to a shell prompt is now `DONE` even when non-shell background processes are still running under the pane -- in particular mngr's own in-pane helpers (the transcript streamers and background-task script, each running a `sleep` loop), which always linger after the agent process is killed. A non-shell process in the pane *foreground* (e.g. a program the user launched in the agent's window) is still treated as a genuine replacement and is never torn down. Unknown-agent-type behavior is unchanged. + +- Sending a message now restarts an agent that is `STOPPED` or `DONE` (neither has a live process to receive the message). For a `DONE` agent the lingering tmux session is torn down first, mirroring `mngr start --restart` -- including its host-lock serialization against `mngr gc` and concurrent starts, via a locked stop-then-start helper now shared with `mngr start --restart` -- so the relaunch actually happens rather than no-op'ing on the existing session. If this automatic (re)start fails (e.g. the relaunched agent never signals readiness), the failure is recorded against the agent in the command's output and exit code, just like a failed send, rather than only being warned about in the log. + +Together this is what the OOM revival path relies on: an agent whose main process was shed is brought back by the next message it receives. diff --git a/vendor/mngr/libs/mngr/docs/commands/secondary/forward.md b/vendor/mngr/libs/mngr/docs/commands/secondary/forward.md index 3dee5205..b17b75b3 100644 --- a/vendor/mngr/libs/mngr/docs/commands/secondary/forward.md +++ b/vendor/mngr/libs/mngr/docs/commands/secondary/forward.md @@ -63,6 +63,7 @@ mngr forward [OPTIONS] | `--preauth-cookie` | text | Pre-shared cookie value accepted in lieu of an OTP-issued cookie. | None | | `--open-browser`, `--no-open-browser` | boolean | Open the printed login URL in the system browser. | `False` | | `--allow-host-loopback` | boolean | Permit dialing host loopback (localhost / 127.0.0.0/8 / ::1) when an agent's registered URL is loopback and no SSH tunnel exists. Off by default: any agent whose SSH info hasn't been published returns a 502 instead of silently serving whatever else is bound to that port on the host. Pass this flag only for setups that intentionally run agents directly on the host. | `False` | +| `--use-http2` | boolean | Terminate TLS and negotiate HTTP/2 (via ALPN) instead of serving plain HTTP/1.1. Removes Chromium's ~6-connection-per-origin ceiling for the workspace UI. The proxy generates a fresh self-signed cert at startup, so only clients that trust it (the minds desktop app) should enable this; a human browser will see a cert warning. | `False` | ## Examples diff --git a/vendor/mngr/libs/mngr/docs/commands/secondary/latchkey.md b/vendor/mngr/libs/mngr/docs/commands/secondary/latchkey.md index d03afb93..c9a7976d 100644 --- a/vendor/mngr/libs/mngr/docs/commands/secondary/latchkey.md +++ b/vendor/mngr/libs/mngr/docs/commands/secondary/latchkey.md @@ -211,7 +211,10 @@ Long-running foreground process that: 1. Initializes the configured ``Latchkey`` (version-checks the binary, adopts or discards any pre-existing detached gateway record). -2. Eagerly spawns the shared ``latchkey gateway`` subprocess. +2. Eagerly spawns the shared ``latchkey gateway`` subprocess and + supervises it: a background health check respawns it (reusing its + original port) if the subprocess dies mid-session, so a crashed + gateway does not silently take agent traffic down. 3. Spawns ``mngr observe --discovery-only --quiet`` and, for every agent discovered, opens a reverse SSH tunnel that bridges the agent's ``127.0.0.1:AGENT_SIDE_LATCHKEY_PORT`` to the host-side diff --git a/vendor/mngr/libs/mngr/docs/commands/secondary/message.md b/vendor/mngr/libs/mngr/docs/commands/secondary/message.md index 646dae5d..2ef29e11 100644 --- a/vendor/mngr/libs/mngr/docs/commands/secondary/message.md +++ b/vendor/mngr/libs/mngr/docs/commands/secondary/message.md @@ -37,7 +37,7 @@ mngr message [OPTIONS] [AGENTS]... | Name | Type | Description | Default | | ---- | ---- | ----------- | ------- | | `--agent` | agent_address | Agent address (NAME[@HOST[.PROVIDER]]) to send message to (can be specified multiple times) | None | -| `--start`, `--no-start` | boolean | Automatically start offline hosts and stopped agents before sending | `False` | +| `--start`, `--no-start` | boolean | Automatically start offline hosts and stopped or exited agents before sending | `False` | ## Message Content diff --git a/vendor/mngr/libs/mngr/docs/concepts/agents.md b/vendor/mngr/libs/mngr/docs/concepts/agents.md index 81199897..0a99c515 100644 --- a/vendor/mngr/libs/mngr/docs/concepts/agents.md +++ b/vendor/mngr/libs/mngr/docs/concepts/agents.md @@ -74,8 +74,8 @@ Assuming the agent's [host](./hosts.md) is in the "running" state, an agent can - **stopped**: the agent folder exists (but there is no tmux session) - **running**: tmux session exists and the expected process exists in pane 0 - **waiting**: the agent is waiting (e.g., for user input or an external event) -- **replaced**: tmux session exists and a different process in pane 0 -- **done**: the tmux session exists and there is no process under the shell for that pane +- **replaced**: tmux session exists and a different program is running in the pane's foreground +- **done**: the tmux session exists and the pane's foreground has dropped back to a shell prompt (the agent process exited; background helper processes may still be running under the pane) If the host's state is not "running", then the agent inherits it state from the host (ex: paused, crashed, etc, see [host lifecycle](./hosts.md#Lifecycle) for more details) diff --git a/vendor/mngr/libs/mngr/imbue/mngr/api/find.py b/vendor/mngr/libs/mngr/imbue/mngr/api/find.py index b7af1667..80f79733 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/api/find.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/api/find.py @@ -16,8 +16,10 @@ from imbue.mngr.api.providers import get_provider_instance from imbue.mngr.config.data_types import MngrContext from imbue.mngr.errors import AgentNotFoundError +from imbue.mngr.errors import AgentNotFoundOnHostError from imbue.mngr.errors import AgentStateInconsistencyError from imbue.mngr.errors import UserInputError +from imbue.mngr.hosts.common import get_agent_state_dir_path from imbue.mngr.hosts.host import Host from imbue.mngr.interfaces.agent import AgentInterface from imbue.mngr.interfaces.host import HostInterface @@ -356,6 +358,11 @@ def ensure_agent_started(agent: AgentInterface, host: OnlineHostInterface, is_st If the agent is stopped and is_start_desired is True, starts the agent. If the agent is stopped and is_start_desired is False, raises UserInputError. + + Purely additive: ``start_agents`` no-ops on an existing tmux session, so a DONE + agent's lingering session (and its pane content) is left exactly as it was. + Callers that need a DONE agent actually relaunched (e.g. to deliver a message) + must use ``revive_done_agent`` instead. """ lifecycle_state = agent.get_lifecycle_state() if lifecycle_state not in ( @@ -378,6 +385,56 @@ def ensure_agent_started(agent: AgentInterface, host: OnlineHostInterface, is_st ) +def start_agents_locked(host: OnlineHostInterface, agent_ids: Sequence[AgentId], is_restart: bool) -> None: + """Start (optionally stop-then-start) agents under the host's cooperative lock. + + The lock serializes the (re)launch against any other operation on this host -- + e.g. the minds desktop client (remote, over SSH) racing a VM/container boot + hook (local), or a concurrent ``mngr gc``. The lock blocks indefinitely; gc + shares it, so a gc that tore down an agent is serialized before us, and the + state-dir check below makes a doomed (re)launch fail with a clear error instead + of trying to boot an agent gc already removed. + + When ``is_restart`` is False the launch is purely additive: an already-running + agent's start is a no-op (the start command exits early when its tmux session + exists), so a racing loser cleanly does nothing. When True, agents are stopped + first -- destructive: each agent's lingering session and every window in it are + killed. + """ + with host.lock_cooperatively(timeout_seconds=None): + for agent_id in agent_ids: + agent_state_dir = get_agent_state_dir_path(host.host_dir, agent_id) + if not host.path_exists(agent_state_dir): + raise AgentNotFoundOnHostError(agent_id, host.id) + + if is_restart: + with log_span("Stopping {} agent(s) for restart", len(agent_ids)): + host.stop_agents(agent_ids) + + with log_span("Starting {} agent(s)", len(agent_ids)): + host.start_agents(agent_ids) + + +def revive_done_agent(agent: AgentInterface, host: OnlineHostInterface) -> None: + """Tear down a DONE agent's lingering tmux session and relaunch it, waiting for readiness. + + A DONE agent's main process has exited but tmux still holds the session open + (e.g. after a ctrl-c, a crash, or an OOM shed). ``start_agents`` + short-circuits on an existing session, so truly reviving the agent requires + tearing the husk down first -- the same locked stop-then-start that + ``mngr start --restart`` performs. The teardown is destructive (it kills the + lingering session and every window in it), so callers that only need the + husk's pane content (e.g. ``capture``) must use ``ensure_agent_started`` + instead, which leaves an existing session untouched. + """ + logger.info("Agent {} is DONE with a lingering tmux session; restarting it", agent.name) + agent.wait_for_ready_signal( + is_creating=False, + start_action=lambda: start_agents_locked(host, [agent.id], is_restart=True), + timeout=agent.get_ready_timeout_seconds(), + ) + + class AgentMatch(FrozenModel): """Information about an agent that matched a search query.""" diff --git a/vendor/mngr/libs/mngr/imbue/mngr/api/message.py b/vendor/mngr/libs/mngr/imbue/mngr/api/message.py index c8858bde..0edafd42 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/api/message.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/api/message.py @@ -14,6 +14,7 @@ from imbue.mngr.api.find import ensure_agent_started from imbue.mngr.api.find import ensure_host_started from imbue.mngr.api.find import group_agents_by_host +from imbue.mngr.api.find import revive_done_agent from imbue.mngr.api.providers import get_provider_instance from imbue.mngr.config.data_types import MngrContext from imbue.mngr.errors import AgentNotFoundOnHostError @@ -195,13 +196,35 @@ def _send_message_to_agent( """ agent_name = str(agent.name) - # Check if agent has a tmux session (only STOPPED agents cannot receive messages) + # (Re)start the agent unless it is live enough to receive a message. STOPPED + # has no tmux session at all; DONE has a lingering session whose agent process + # already exited (a ctrl-c, a crash, or an OOM shed leaves tmux holding the + # pane open on a bare shell). In both cases there is no agent to deliver to, so + # a raw send would just type the message into a dead shell and silently lose + # it. A DONE husk must be torn down before the relaunch actually happens + # (revive_done_agent), whereas a STOPPED agent just needs a plain start. lifecycle_state = agent.get_lifecycle_state() - if lifecycle_state == AgentLifecycleState.STOPPED: + if lifecycle_state in (AgentLifecycleState.STOPPED, AgentLifecycleState.DONE): if is_start_desired: - ensure_agent_started(agent, host, is_start_desired=True) + # Record a failed (re)start against this agent just like a failed send, + # so it shows up in the result (and the exit code) instead of only in a + # host-level warning log. + try: + if lifecycle_state == AgentLifecycleState.DONE: + revive_done_agent(agent, host) + else: + ensure_agent_started(agent, host, is_start_desired=True) + except MngrError as e: + error_msg = str(e) + with result_lock: + result.failed_agents.append((agent_name, error_msg)) + if on_error: + on_error(agent_name, error_msg) + if error_behavior == ErrorBehavior.ABORT: + raise MngrError(error_msg) from e + return else: - error_msg = f"Agent has no tmux session (state: {lifecycle_state.value})" + error_msg = f"Agent is not running (state: {lifecycle_state.value})" with result_lock: result.failed_agents.append((agent_name, error_msg)) if on_error: diff --git a/vendor/mngr/libs/mngr/imbue/mngr/api/message_test.py b/vendor/mngr/libs/mngr/imbue/mngr/api/message_test.py index fabb516b..404c732a 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/api/message_test.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/api/message_test.py @@ -1,15 +1,23 @@ +from collections.abc import Callable from pathlib import Path +from threading import Lock import pytest +from imbue.mngr.agents.base_agent import BaseAgent from imbue.mngr.agents.base_agent import SendKeysAgent from imbue.mngr.api.create import CreateAgentOptions from imbue.mngr.api.find import find_all_agents from imbue.mngr.api.message import MessageResult +from imbue.mngr.api.message import _send_message_to_agent from imbue.mngr.api.message import send_message_to_agents +from imbue.mngr.cli.testing import create_test_agent +from imbue.mngr.config.data_types import AgentTypeConfig from imbue.mngr.config.data_types import MngrContext +from imbue.mngr.errors import AgentStartError from imbue.mngr.errors import SendMessageError from imbue.mngr.hosts.host import Host +from imbue.mngr.hosts.tmux import TmuxWindowTarget from imbue.mngr.primitives import AgentLifecycleState from imbue.mngr.primitives import AgentName from imbue.mngr.primitives import AgentTypeName @@ -18,6 +26,7 @@ from imbue.mngr.primitives import HostName from imbue.mngr.providers.local.instance import LOCAL_HOST_NAME from imbue.mngr.providers.local.instance import LocalProviderInstance +from imbue.mngr.utils.polling import wait_for def test_message_result_initializes_with_empty_lists() -> None: @@ -140,10 +149,11 @@ def test_send_message_to_agents_fails_for_stopped_agent( # Clean up host.destroy_agent(agent) - # Should have failed because agent has no tmux session + # Should have failed because the agent is not running (no tmux session) assert len(result.failed_agents) == 1 assert result.failed_agents[0][0] == "stopped-test" - assert "no tmux session" in result.failed_agents[0][1] + assert "not running" in result.failed_agents[0][1] + assert "STOPPED" in result.failed_agents[0][1] @pytest.mark.tmux @@ -196,6 +206,144 @@ def test_send_message_to_agents_starts_stopped_agent_when_start_desired( assert len(error_agents) == 0 +@pytest.mark.tmux +# real agent setup/teardown plus a stop-and-restart can exceed the 10s default. +@pytest.mark.timeout(30) +def test_send_message_to_agents_revives_done_agent_when_start_desired( + temp_work_dir: Path, + temp_mngr_ctx: MngrContext, + local_provider: LocalProviderInstance, +) -> None: + """Messaging a DONE agent must revive it, not type the message into the husk shell. + + A DONE agent is one whose main process died (here: a ctrl-c, standing in for a + crash or an OOM shed) while tmux kept the session open on a bare shell. This is + distinct from STOPPED (no session at all). Because ``start_agents`` + short-circuits on an existing session, reviving a DONE agent requires tearing + the husk down first -- otherwise the message is delivered into the leftover + shell and silently lost. This guards the OOM revival path, which relies on a + later message restarting a shed agent. + """ + host = local_provider.create_host(HostName(LOCAL_HOST_NAME)) + assert isinstance(host, Host) + + agent = host.create_agent_state( + work_dir_path=temp_work_dir, + options=CreateAgentOptions( + name=AgentName("revive-done-test"), + agent_type=AgentTypeName("generic"), + command=CommandString("sleep 847271"), + ), + ) + host.start_agents([agent.id]) + + # Confirm the agent is live before we kill its process. + wait_for( + lambda: agent.get_lifecycle_state() in (AgentLifecycleState.RUNNING, AgentLifecycleState.WAITING), + error_message="Expected agent to be running before killing its process", + ) + + # Kill the agent's process but leave the tmux session up, exactly as a ctrl-c + # (or an OOM shed of the main process) would: the pane drops back to its shell, + # so the agent reports DONE rather than STOPPED. + session_name = temp_mngr_ctx.config.agent_session_name(agent.name) + window_name = temp_mngr_ctx.config.tmux.primary_window_name + window_target = TmuxWindowTarget(session_name=session_name, window=window_name) + host.execute_idempotent_command( + f"tmux send-keys -t {window_target.as_shell_arg()} C-c", + timeout_seconds=5.0, + ) + wait_for( + lambda: agent.get_lifecycle_state() == AgentLifecycleState.DONE, + error_message="Expected agent lifecycle state to be DONE after killing its process", + ) + + matches = find_all_agents( + addresses=(), + filter_all=True, + target_state=None, + mngr_ctx=temp_mngr_ctx, + ) + + error_agents: list[tuple[str, str]] = [] + result = send_message_to_agents( + mngr_ctx=temp_mngr_ctx, + message_content="Welcome back", + agents_to_message=matches, + is_start_desired=True, + on_error=lambda name, err: error_agents.append((name, err)), + ) + + # The decisive check: the DONE husk was torn down and the agent relaunched, so + # a fresh process is running again. With the bug, the agent would stay DONE + # (the message having been typed into the dead shell). + wait_for( + lambda: agent.get_lifecycle_state() in (AgentLifecycleState.RUNNING, AgentLifecycleState.WAITING), + error_message="Expected the DONE agent to be revived to a running state after messaging", + ) + + # Clean up + host.destroy_agent(agent) + + assert "revive-done-test" in result.successful_agents + assert error_agents == [] + + +class _ReviveFailingAgent(BaseAgent[AgentTypeConfig]): + """Test agent that reports DONE and whose revive fails with AgentStartError.""" + + def get_lifecycle_state(self) -> AgentLifecycleState: + return AgentLifecycleState.DONE + + def wait_for_ready_signal( + self, + is_creating: bool, + start_action: Callable[[], None], + timeout: float | None = None, + ) -> None: + raise AgentStartError(str(self.name), "agent did not become ready") + + +def test_send_message_records_failure_when_revive_fails( + temp_work_dir: Path, + local_provider: LocalProviderInstance, +) -> None: + """A failed revive must land in failed_agents, not vanish into a host-level log. + + If reviving a DONE agent raises (e.g. the ready-wait times out), the failure has + to be recorded against the agent so `mngr message --start` reports it and exits + non-zero, instead of exiting 0 with the agent missing from both result lists. + """ + agent = create_test_agent( + local_provider, + temp_work_dir, + agent_config=None, + agent_type=None, + extra_data=None, + agent_class=_ReviveFailingAgent, + ) + + result = MessageResult() + errors: list[tuple[str, str]] = [] + _send_message_to_agent( + agent=agent, + host=agent.host, + message_content="hello", + result=result, + result_lock=Lock(), + error_behavior=ErrorBehavior.CONTINUE, + is_start_desired=True, + on_success=None, + on_error=lambda name, error: errors.append((name, error)), + ) + + assert result.successful_agents == [] + assert result.failed_agents == [ + (str(agent.name), f"Failed to start agent {agent.name}: agent did not become ready") + ] + assert errors == result.failed_agents + + @pytest.mark.tmux # real agent setup/teardown occasionally exceeds the 10s default. @pytest.mark.timeout(30) diff --git a/vendor/mngr/libs/mngr/imbue/mngr/cli/conftest.py b/vendor/mngr/libs/mngr/imbue/mngr/cli/conftest.py index a4a6b642..a0a4d5a5 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/cli/conftest.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/cli/conftest.py @@ -1,3 +1,4 @@ +import os from collections.abc import Callable from pathlib import Path from typing import Generator @@ -17,6 +18,7 @@ from imbue.mngr.cli.destroy import destroy from imbue.mngr.cli.events import events from imbue.mngr.cli.exec import exec_command +from imbue.mngr.cli.extras import _CLAUDE_CODE_PLUGINS from imbue.mngr.cli.extras import extras from imbue.mngr.cli.gc import gc from imbue.mngr.cli.help import help_command @@ -29,6 +31,7 @@ from imbue.mngr.cli.snapshot import snapshot from imbue.mngr.cli.start import start from imbue.mngr.cli.stop import stop +from imbue.mngr.cli.testing import write_stub_claude_cli from imbue.mngr.cli.transcript import transcript from imbue.mngr.config.data_types import CreateCliOptions from imbue.mngr.main import cli @@ -214,6 +217,23 @@ def test_something(create_test_agent): cleanup_tmux_session(session_name) +@pytest.fixture +def stub_claude_on_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Prepend a stub ``claude`` CLI to PATH that reports every known plugin as installed. + + Extras CLI tests that reach the claude-plugin status probe through the real + command path use this so they never spawn the real ``claude`` (a Node + process whose startup on a contended CI sandbox trips the global 10s + offload pytest-timeout). Reporting all plugins installed also makes the + install flow short-circuit before its interactive-terminal check, so the + tests behave identically with or without a TTY. Returns the stub's bin dir. + """ + bin_dir = tmp_path / "stub-claude-bin" + write_stub_claude_cli(bin_dir, installed_plugin_ids=[plugin.install_ref for plugin in _CLAUDE_CODE_PLUGINS]) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + return bin_dir + + @pytest.fixture def editor_recovery_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: """Provide a temporary recovery directory and EDITOR for editor recovery tests. diff --git a/vendor/mngr/libs/mngr/imbue/mngr/cli/extras_test.py b/vendor/mngr/libs/mngr/imbue/mngr/cli/extras_test.py index e6914c72..0229299f 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/cli/extras_test.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/cli/extras_test.py @@ -1,6 +1,5 @@ """Tests for the mngr extras command.""" -import json import os from pathlib import Path @@ -23,6 +22,7 @@ from imbue.mngr.cli.extras import _print_extras_status from imbue.mngr.cli.extras import _read_current_default_agent_type from imbue.mngr.cli.extras import extras +from imbue.mngr.cli.testing import write_stub_claude_cli # A byte-identical copy of an old self-contained zsh completion function mngr generated # before the managed-shim model (any baked python path). strip_legacy_completion_block @@ -372,22 +372,24 @@ def test_print_extras_status_runs_without_error() -> None: ) -# Probes plugin / shell-completion / claude-plugin status, which can stall on a -# contended CI sandbox and trip the global 10s pytest-timeout even though it runs -# in well under a second locally. Bump the per-test timeout for headroom. -@pytest.mark.timeout(30) -def test_extras_no_args_shows_status(cli_runner: CliRunner) -> None: - """Running 'mngr extras' with no flags shows status.""" +def test_extras_no_args_shows_status(cli_runner: CliRunner, stub_claude_on_path: Path) -> None: + """Running 'mngr extras' with no flags shows status. + + The stub claude keeps the status probe off the real Node CLI, whose + startup on a contended CI sandbox tripped the global 10s offload timeout. + """ result = cli_runner.invoke(extras, []) assert result.exit_code == 0 assert "Extras" in result.output + assert "claude-plugin" in result.output -def test_extras_interactive_mode(cli_runner: CliRunner) -> None: +def test_extras_interactive_mode(cli_runner: CliRunner, stub_claude_on_path: Path) -> None: """Running 'mngr extras -i' walks through all extras interactively.""" - # In the test environment, has_interactive_terminal() returns False - # (no /dev/tty), so each _install_* short-circuits before reaching the - # urwid picker. + # The stub claude reports every plugin installed, so the claude-plugin step + # short-circuits deterministically (no Node startup, no urwid picker even + # when a /dev/tty is available); the other _install_* steps short-circuit + # on their own has_interactive_terminal() checks in CI. result = cli_runner.invoke(extras, ["-i"]) assert result.exit_code == 0 assert "Plugins" in result.output @@ -407,10 +409,17 @@ def test_extras_completion_subcommand(cli_runner: CliRunner) -> None: assert result.exit_code == 0 -def test_extras_claude_plugin_subcommand(cli_runner: CliRunner) -> None: - """The 'extras claude-plugin' subcommand should work.""" +def test_extras_claude_plugin_subcommand(cli_runner: CliRunner, stub_claude_on_path: Path) -> None: + """The 'extras claude-plugin' subcommand should work. + + The stub claude replaces the real Node CLI (whose startup on a contended + CI sandbox tripped the global 10s offload timeout) and reports every + plugin installed, so the command deterministically short-circuits with + the already-installed message on any machine. + """ result = cli_runner.invoke(extras, ["claude-plugin"]) assert result.exit_code == 0 + assert "All Claude Code plugins are already installed." in result.output def test_extras_completion_yes_flag(cli_runner: CliRunner) -> None: @@ -430,17 +439,9 @@ def test_extras_claude_plugin_yes_flag(cli_runner: CliRunner, tmp_path: Path, mo installed -- letting us assert that the already-installed plugin is left untouched. """ - stub_claude = tmp_path / "claude" - # `claude plugin list --json` returns an array of objects keyed by `id`; - # report imbue-code-guardian as already installed and succeed otherwise. - listing = json.dumps( - [{"id": "imbue-code-guardian@imbue-code-guardian", "version": "0.2.1", "scope": "project", "enabled": True}] - ) - stub_claude.write_text( - f'#!/usr/bin/env bash\nif [ "$1" = "plugin" ] && [ "$2" = "list" ]; then\n echo \'{listing}\'\nfi\nexit 0\n' - ) - stub_claude.chmod(0o755) - monkeypatch.setenv("PATH", f"{tmp_path}{os.pathsep}{os.environ['PATH']}") + bin_dir = tmp_path / "stub-claude-bin" + write_stub_claude_cli(bin_dir, installed_plugin_ids=["imbue-code-guardian@imbue-code-guardian"]) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") result = cli_runner.invoke(extras, ["claude-plugin", "-y"]) assert result.exit_code == 0 @@ -576,7 +577,7 @@ def test_extras_config_yes_flag(cli_runner: CliRunner) -> None: assert result.exit_code == 0 -def test_extras_interactive_includes_default_type(cli_runner: CliRunner) -> None: +def test_extras_interactive_includes_default_type(cli_runner: CliRunner, stub_claude_on_path: Path) -> None: """Running 'mngr extras -i' walks through the default agent type prompt.""" result = cli_runner.invoke(extras, ["-i"]) assert result.exit_code == 0 diff --git a/vendor/mngr/libs/mngr/imbue/mngr/cli/message.py b/vendor/mngr/libs/mngr/imbue/mngr/cli/message.py index b33defc4..b8f68d67 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/cli/message.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/cli/message.py @@ -64,7 +64,7 @@ class MessageCliOptions(CommonCliOptions): "--start/--no-start", default=False, show_default=True, - help="Automatically start offline hosts and stopped agents before sending", + help="Automatically start offline hosts and stopped or exited agents before sending", ) @optgroup.group("Message Content") @optgroup.option( diff --git a/vendor/mngr/libs/mngr/imbue/mngr/cli/start.py b/vendor/mngr/libs/mngr/imbue/mngr/cli/start.py index cf9fa11d..4ac44d62 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/cli/start.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/cli/start.py @@ -15,6 +15,7 @@ from imbue.mngr.api.find import ensure_host_started from imbue.mngr.api.find import find_all_agents from imbue.mngr.api.find import group_agents_by_host +from imbue.mngr.api.find import start_agents_locked from imbue.mngr.api.providers import get_provider_instance from imbue.mngr.cli.address_params import AGENT_ADDRESS from imbue.mngr.cli.address_params import HOST_ADDRESS @@ -32,8 +33,6 @@ from imbue.mngr.config.data_types import CommonCliOptions from imbue.mngr.config.data_types import MngrContext from imbue.mngr.config.data_types import OutputOptions -from imbue.mngr.errors import AgentNotFoundOnHostError -from imbue.mngr.hosts.common import get_agent_state_dir_path from imbue.mngr.interfaces.agent import AgentInterface from imbue.mngr.interfaces.agent import require_interactive_agent from imbue.mngr.interfaces.host import OnlineHostInterface @@ -239,30 +238,7 @@ def _start_agents( agent_ids = [match.agent_id for match in agent_list] - # Serialize agent (re)launch against any other operation on this host via - # the shared host lock -- e.g. the minds desktop client (remote, over SSH) - # racing a VM/container boot hook (local), or a concurrent `mngr gc`. The - # lock blocks indefinitely; once it is held, an already-running agent's - # launch is a no-op (the start command exits early when its tmux session - # exists), so the loser cleanly does nothing. - with online_host.lock_cooperatively(timeout_seconds=None): - # gc shares this lock, so a gc that tore down the host/agent is now - # serialized before us. Lightweight check (just the agent state dir, - # not a full host revalidation) so a doomed start fails with a clear - # error instead of trying to boot an agent gc already removed. - for match in agent_list: - agent_state_dir = get_agent_state_dir_path(online_host.host_dir, match.agent_id) - if not online_host.path_exists(agent_state_dir): - raise AgentNotFoundOnHostError(match.agent_id, online_host.id) - - # Stop agents first when restarting - if is_restart: - with log_span("Stopping {} agent(s) for restart", len(agent_ids)): - online_host.stop_agents(agent_ids) - - # Start agents on this host - with log_span("Starting {} agent(s)", len(agent_ids)): - online_host.start_agents(agent_ids) + start_agents_locked(online_host, agent_ids, is_restart=is_restart) # Emit discovery events for agents and host emit_discovery_events_for_host(mngr_ctx.config, online_host) diff --git a/vendor/mngr/libs/mngr/imbue/mngr/cli/test_extras.py b/vendor/mngr/libs/mngr/imbue/mngr/cli/test_extras.py new file mode 100644 index 00000000..b8ef1b11 --- /dev/null +++ b/vendor/mngr/libs/mngr/imbue/mngr/cli/test_extras.py @@ -0,0 +1,26 @@ +"""Acceptance tests for the mngr extras command against the real environment.""" + +import pytest +from click.testing import CliRunner + +from imbue.mngr.cli.extras import extras + + +@pytest.mark.acceptance +@pytest.mark.timeout(60) +def test_extras_status_probes_real_claude_cli(cli_runner: CliRunner) -> None: + """`mngr extras` status works against the real environment, with no stubs. + + The unit tests in extras_test.py deliberately replace the ``claude`` CLI + with a fast stub, because the real one is a Node process whose startup on + a contended sandbox can cross the global 10s offload pytest-timeout. This + acceptance test keeps the real shell-out covered (when claude is + installed, the status probe runs ``claude plugin list --json`` for real) + under a timeout sized for Node startup. + """ + result = cli_runner.invoke(extras, []) + assert result.exit_code == 0 + assert "Extras" in result.output + # The claude-plugin line renders in both environments: "claude not + # installed" without claude, per-plugin statuses with it. + assert "claude-plugin" in result.output diff --git a/vendor/mngr/libs/mngr/imbue/mngr/cli/testing.py b/vendor/mngr/libs/mngr/imbue/mngr/cli/testing.py index 8742b1dd..798f66a2 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/cli/testing.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/cli/testing.py @@ -1,5 +1,6 @@ import json from collections.abc import Mapping +from collections.abc import Sequence from datetime import datetime from datetime import timezone from pathlib import Path @@ -198,3 +199,24 @@ def create_agent_with_sample_transcript( ) write_common_transcript_events(events_dir, events if events is not None else SAMPLE_TRANSCRIPT_EVENTS) return agent_id, events_dir + + +def write_stub_claude_cli(bin_dir: Path, installed_plugin_ids: Sequence[str]) -> Path: + """Write a fast stub ``claude`` executable into ``bin_dir`` and return its path. + + ``claude plugin list --json`` reports exactly ``installed_plugin_ids`` as + installed; every other invocation succeeds silently. Extras CLI tests + prepend ``bin_dir`` to PATH so the real command path is exercised end to + end (subprocess spawn, JSON parsing) without paying the real ``claude`` + CLI's Node startup -- which on a contended CI sandbox can cross the global + 10s offload pytest-timeout -- and without depending on whether (and with + which plugins) claude happens to be installed on the machine. + """ + listing = json.dumps([{"id": plugin_id, "scope": "user", "enabled": True} for plugin_id in installed_plugin_ids]) + bin_dir.mkdir(parents=True, exist_ok=True) + stub_path = bin_dir / "claude" + stub_path.write_text( + f'#!/usr/bin/env bash\nif [ "$1" = "plugin" ] && [ "$2" = "list" ]; then\n echo \'{listing}\'\nfi\nexit 0\n' + ) + stub_path.chmod(0o755) + return stub_path diff --git a/vendor/mngr/libs/mngr/imbue/mngr/cli/transcript.py b/vendor/mngr/libs/mngr/imbue/mngr/cli/transcript.py index 43d814e4..9c664011 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/cli/transcript.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/cli/transcript.py @@ -171,6 +171,20 @@ def _format_event_human(event: dict[str, Any]) -> str: else: # Unknown part type (e.g. a future reasoning part): nothing to render here. continue + if not lines: + # FIXME: MIND-113, later remove this fallback logic. Flat text/tool_calls + # for agents on an emitter predating parts[] (before 2026-06-15) so their + # turns aren't blank; new agents all fill parts[], so this serves only + # existing old-emitter agents. Remove once they age out. + text = event.get("text", "") + if text: + lines.append(text) + for tool_call in event.get("tool_calls") or []: + if not isinstance(tool_call, dict): + continue + tool_name = tool_call.get("tool_name", "unknown") + preview = tool_call.get("input_preview", "") + lines.append(f" -> {tool_name}({preview})") body = "\n".join(lines) if lines else "(no content)" return f"[{timestamp}] assistant:\n{body}" diff --git a/vendor/mngr/libs/mngr/imbue/mngr/cli/transcript_test.py b/vendor/mngr/libs/mngr/imbue/mngr/cli/transcript_test.py index 51ffbf66..198b7355 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/cli/transcript_test.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/cli/transcript_test.py @@ -233,6 +233,27 @@ def test_format_event_human_assistant_message_with_tool_calls() -> None: assert "-> Read(" in result +def test_format_event_human_assistant_message_without_parts_uses_flat_text() -> None: + # Claude's common_transcript fills the flat text/tool_calls and omits parts[]; + # the text must still render rather than collapsing to "(no content)". + event = { + "type": "assistant_message", + "timestamp": "2026-01-01T00:00:01Z", + "text": "Merge cleanup complete.", + "tool_calls": [{"tool_call_id": "c1", "tool_name": "Bash", "input_preview": '{"command":"git push"}'}], + } + result = _format_event_human(event) + assert "Merge cleanup complete." in result + assert "-> Bash(" in result + assert "(no content)" not in result + + +def test_format_event_human_assistant_message_empty_is_no_content() -> None: + event = {"type": "assistant_message", "timestamp": "2026-01-01T00:00:01Z", "text": "", "tool_calls": []} + result = _format_event_human(event) + assert "(no content)" in result + + def test_format_event_human_tool_result() -> None: event = { "type": "tool_result", diff --git a/vendor/mngr/libs/mngr/imbue/mngr/hosts/common.py b/vendor/mngr/libs/mngr/imbue/mngr/hosts/common.py index 9ec0b06e..794d30df 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/hosts/common.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/hosts/common.py @@ -394,15 +394,30 @@ def determine_lifecycle_state( AgentLifecycleState.RUNNING_UNKNOWN_AGENT_TYPE if not is_agent_type_known else AgentLifecycleState.REPLACED ) - # Check for non-shell descendant processes + # For a KNOWN type the agent's process was checked above and is definitively + # absent, so a shell in the pane's foreground means the pane has dropped back to + # a prompt: the agent exited -> DONE. This must precede the descendant scan + # below, because mngr's own in-pane helpers (the transcript streamers and + # background-task script, each running a `sleep` loop) linger as *background* + # descendants after the agent process is killed and must not read as a + # replacement. For an UNKNOWN type this shortcut does not apply: a background + # descendant might be the agent itself under a name we don't recognize. + if is_agent_type_known and current_command in SHELL_COMMANDS: + return AgentLifecycleState.DONE + + # Something non-shell is running under the pane while the foreground is not a + # bare prompt. For a KNOWN type that is another program occupying the window + # (-> REPLACED); for an UNKNOWN type it may be the agent running under a name we + # don't recognize (-> RUNNING_UNKNOWN_AGENT_TYPE). non_shell_processes = [p for p in descendant_names if p not in SHELL_COMMANDS] if non_shell_processes: return replaced_state - # Agent is not running. Determine DONE vs REPLACED by checking whether - # the pane process is a shell (agent exited normally) or something else - # (agent was replaced by another program). Use ps as authoritative source - # since tmux may report a stale modified title. + # Only shells (or nothing) left under the pane. Determine DONE vs REPLACED by + # checking whether the pane's foreground is a shell (agent exited to a prompt -> + # DONE) or another program (-> REPLACED). Use ps's pane comm as an authoritative + # cross-check, since tmux may report a stale modified title (e.g. Claude Code's + # version string). pane_comm = comm_by_pid.get(pane_pid) if current_command in SHELL_COMMANDS or (pane_comm is not None and pane_comm in SHELL_COMMANDS): return AgentLifecycleState.DONE diff --git a/vendor/mngr/libs/mngr/imbue/mngr/hosts/common_test.py b/vendor/mngr/libs/mngr/imbue/mngr/hosts/common_test.py index c5d7eab1..89a4a67d 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/hosts/common_test.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/hosts/common_test.py @@ -109,9 +109,18 @@ def test_lifecycle_running_when_descendant_matches() -> None: assert determine_lifecycle_state("0|bash|123", True, "claude", ps_output) == AgentLifecycleState.RUNNING -def test_lifecycle_replaced_when_non_shell_descendant() -> None: +def test_lifecycle_done_when_non_shell_descendant_but_foreground_is_shell_known_type() -> None: + """A known-type agent whose process is gone but whose pane has dropped to a shell + prompt is DONE, even with a non-shell descendant still running. + + Every real claude agent keeps mngr's own in-pane helpers (transcript streamers, + background-task script -- each a `sleep` loop) running as descendants. After the + agent process is killed (ctrl-c / crash / OOM shed) those linger, so a non-shell + descendant must NOT be read as "replaced by another program" when the foreground + is a shell. Only a non-shell *foreground* means a real replacement. + """ ps_output = "200 123 python3\n" - assert determine_lifecycle_state("0|bash|123", True, "claude", ps_output) == AgentLifecycleState.REPLACED + assert determine_lifecycle_state("0|bash|123", True, "claude", ps_output) == AgentLifecycleState.DONE def test_lifecycle_done_when_shell_only() -> None: @@ -164,11 +173,33 @@ def test_lifecycle_running_unknown_when_pane_pid_not_in_ps_and_unknown_type() -> ) -def test_lifecycle_replaced_when_non_shell_descendant_and_known_type() -> None: - """Verify that known types still get REPLACED (not RUNNING_UNKNOWN_AGENT_TYPE).""" - ps_output = "200 123 python3\n" +def test_lifecycle_replaced_when_non_shell_foreground_and_known_type() -> None: + """A known-type agent with a non-shell process in the *foreground* (pane comm is + not a shell) is genuinely REPLACED -- distinct from a non-shell *descendant* under + a shell prompt, which is DONE (see the test above).""" + ps_output = "123 1 python3\n" + assert ( + determine_lifecycle_state("0|python3|123", True, "claude", ps_output, is_agent_type_known=True) + == AgentLifecycleState.REPLACED + ) + + +def test_lifecycle_replaced_when_non_shell_foreground_child_and_known_type() -> None: + """A known-type agent whose pane shell has a non-shell *foreground* child is + REPLACED, not DONE. + + This is the common takeover shape: the pane's root process is still the shell + (so its ps comm is a shell), but another program runs in the foreground -- e.g. + a window deliberately held by `sleep infinity && claude`, or a user who ctrl-c'd + the agent and launched something else. tmux reports the foreground child as + pane_current_command, which is what distinguishes this from a pane sitting at a + bare prompt with only *background* helpers left (DONE, see above). Messaging + treats DONE as revivable and tears the session down, so misreading this as DONE + would destroy the occupying program. + """ + ps_output = "123 1 bash\n200 123 sleep\n" assert ( - determine_lifecycle_state("0|bash|123", True, "claude", ps_output, is_agent_type_known=True) + determine_lifecycle_state("0|sleep|123", True, "claude", ps_output, is_agent_type_known=True) == AgentLifecycleState.REPLACED ) diff --git a/vendor/mngr/libs/mngr/imbue/mngr/hosts/host.py b/vendor/mngr/libs/mngr/imbue/mngr/hosts/host.py index 1160f0f4..2a603781 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/hosts/host.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/hosts/host.py @@ -29,11 +29,6 @@ from pydantic import ValidationError from pyinfra.api.command import StringCommand from pyinfra.connectors.util import CommandOutput -from tenacity import retry -from tenacity import retry_if_exception -from tenacity import stop_after_attempt -from tenacity import wait_chain -from tenacity import wait_fixed from imbue.concurrency_group.errors import ProcessError from imbue.concurrency_group.errors import ProcessTimeoutError @@ -69,6 +64,8 @@ from imbue.mngr.hosts.offline_host import BaseHost from imbue.mngr.hosts.offline_host import apply_rename_to_agent_data from imbue.mngr.hosts.outer_host import OuterHost +from imbue.mngr.hosts.outer_host import is_transient_ssh_error +from imbue.mngr.hosts.outer_host import retry_on_transient_ssh_error from imbue.mngr.hosts.tmux import TmuxSessionTarget from imbue.mngr.hosts.tmux import TmuxWindowTarget from imbue.mngr.interfaces.agent import AgentInterface @@ -159,48 +156,6 @@ def _try_acquire_flock(lock_file: io.TextIOWrapper) -> bool: return False -@pure -def _is_transient_ssh_error(exception: BaseException) -> bool: - """Check if the exception is a transient SSH connection error worth retrying. - - Matches: - - OSError with "Socket is closed" (stale socket from pyinfra) - - SSHException (e.g. "SSH session not active" when transport dies), - including ChannelException (server refused to open a new channel, - e.g. MaxSessions limit -- the transport may still be alive) - - EOFError (remote end closed connection) - - TimeoutError (pyinfra read_output_buffers timeout when the remote - sshd is reloaded mid-command, e.g. during cloud-init bootstrap). - Note: ``TimeoutError`` is an OSError subclass on Python 3, so this - check must precede any narrower OSError handling. - """ - if isinstance(exception, OSError) and "Socket is closed" in str(exception): - return True - if isinstance(exception, SSHException): - return True - if isinstance(exception, EOFError): - return True - if isinstance(exception, TimeoutError): - return True - return False - - -# Shared retry decorator for file operations that encounter transient SSH -# connection errors. Retries after (0, 1, 3, 6) seconds for a total -# backoff window of ~10 seconds. -_retry_on_transient_ssh_error = retry( - retry=retry_if_exception(_is_transient_ssh_error), - stop=stop_after_attempt(5), - wait=wait_chain( - wait_fixed(0), - wait_fixed(1), - wait_fixed(3), - wait_fixed(6), - ), - reraise=True, -) - - def _get_ssh_transport(pyinfra_host: Any) -> Transport | None: """Extract the paramiko Transport from a pyinfra host, or None for non-SSH connectors.""" try: @@ -909,7 +864,7 @@ def _hold_remote_host_lock(self, lock_file_path: Path, timeout_seconds: float | channel.close() logger.trace("Released host lock (over SSH)") - @_retry_on_transient_ssh_error + @retry_on_transient_ssh_error def _open_remote_lock_channel(self, lock_file_path: Path, timeout_seconds: float | None) -> Channel: """Open an SSH channel that holds the remote host flock, retrying transient SSH failures. @@ -932,7 +887,7 @@ def _open_remote_lock_channel(self, lock_file_path: Path, timeout_seconds: float try: channel = transport.open_session() except (OSError, EOFError, SSHException) as e: - if _is_transient_ssh_error(e): + if is_transient_ssh_error(e): logger.debug("Transient SSH error opening host-lock channel: {}, disconnecting for retry", e) self.connector.host.disconnect() raise @@ -943,7 +898,7 @@ def _open_remote_lock_channel(self, lock_file_path: Path, timeout_seconds: float _wait_for_remote_lock_acquired(channel) is_lock_acquired = True except (OSError, EOFError, SSHException) as e: - if _is_transient_ssh_error(e): + if is_transient_ssh_error(e): logger.debug("Transient SSH error acquiring host lock: {}, disconnecting for retry", e) self.connector.host.disconnect() raise diff --git a/vendor/mngr/libs/mngr/imbue/mngr/hosts/host_test.py b/vendor/mngr/libs/mngr/imbue/mngr/hosts/host_test.py index 4871ee60..8c9b5e5d 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/hosts/host_test.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/hosts/host_test.py @@ -47,7 +47,6 @@ from imbue.mngr.hosts.host import _build_remote_lock_command from imbue.mngr.hosts.host import _build_start_agent_shell_command from imbue.mngr.hosts.host import _format_env_file -from imbue.mngr.hosts.host import _is_transient_ssh_error from imbue.mngr.hosts.host import _merge_agent_type_provisioning from imbue.mngr.hosts.host import _parse_boot_time_output from imbue.mngr.hosts.host import _parse_uptime_output @@ -1484,31 +1483,6 @@ def test_execute_idempotent_command_raises_command_timeout_error_on_local_timeou local_host.execute_idempotent_command("sleep 10", timeout_seconds=1, raise_on_timeout=True) -@pytest.mark.parametrize( - ("exception", "expected"), - [ - (OSError("Socket is closed"), True), - (OSError("No such file or directory"), False), - (ValueError("Socket is closed"), False), - (SSHException("SSH session not active"), True), - (ChannelException(2, "open failed"), True), - (EOFError(), True), - (TimeoutError("Timed out reading output"), True), - ], - ids=[ - "socket-closed", - "other-os-error", - "non-os-error", - "ssh-exception", - "channel-exception", - "eof-error", - "timeout-error", - ], -) -def test_is_transient_ssh_error(exception: BaseException, expected: bool) -> None: - assert _is_transient_ssh_error(exception) is expected - - class _FakeLockChannel: """Fake paramiko Channel for the SSH host-lock exec path. diff --git a/vendor/mngr/libs/mngr/imbue/mngr/hosts/outer_host.py b/vendor/mngr/libs/mngr/imbue/mngr/hosts/outer_host.py index 0bc82de4..401f2269 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/hosts/outer_host.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/hosts/outer_host.py @@ -54,6 +54,7 @@ from imbue.concurrency_group.subprocess_utils import FinishedProcess from imbue.imbue_common.mutable_model import MutableModel +from imbue.imbue_common.pure import pure from imbue.mngr.config.data_types import MngrContext from imbue.mngr.errors import HostAuthenticationError from imbue.mngr.errors import HostConnectionError @@ -192,29 +193,40 @@ def _sftp_walk(sftp: SFTPClient, dir_path: str, recursive: bool) -> list[VolumeF return entries -def _is_transient_ssh_error(exception: BaseException) -> bool: - """Check if the exception is a transient SSH connection error worth retrying.""" +@pure +def is_transient_ssh_error(exception: BaseException) -> bool: + """Check if the exception is a transient SSH connection error worth retrying. + + Matches: + - OSError with "Socket is closed" (stale socket from pyinfra) + - SSHException (e.g. "SSH session not active" when transport dies), + including ChannelException (server refused to open a new channel, + e.g. MaxSessions limit -- the transport may still be alive) + - EOFError (remote end closed connection) + - TimeoutError (pyinfra read_output_buffers timeout when the remote + sshd is reloaded mid-command, e.g. during cloud-init bootstrap). + Note: ``TimeoutError`` is an OSError subclass on Python 3, but the + OSError branch above only matches on its "Socket is closed" message, + so bare timeouts fall through and need this explicit branch to be + classified transient. + """ if isinstance(exception, OSError) and "Socket is closed" in str(exception): return True if isinstance(exception, SSHException): return True if isinstance(exception, EOFError): return True - # pyinfra raises a bare ``TimeoutError`` from - # ``pyinfra.connectors.util.read_output_buffers`` when an SSH command - # response doesn't arrive within the per-command timeout -- e.g. when - # the remote sshd is reloaded mid-read during cloud-init bootstrap. - # Treat that as transient: the underlying channel is dead, but a fresh - # connection on retry should succeed once the disruption settles. - # ``TimeoutError`` is a subclass of ``OSError`` on Python 3, so this - # check must precede any general OSError handling. if isinstance(exception, TimeoutError): return True return False -_retry_on_transient_ssh_error = retry( - retry=retry_if_exception(_is_transient_ssh_error), +# Shared retry decorator for SSH operations that encounter transient +# connection errors. Retries after (0, 1, 3, 6) seconds for a total +# backoff window of ~10 seconds. Also used by the Host subclass in +# ``imbue.mngr.hosts.host``. +retry_on_transient_ssh_error = retry( + retry=retry_if_exception(is_transient_ssh_error), stop=stop_after_attempt(5), wait=wait_chain( wait_fixed(0), @@ -226,6 +238,31 @@ def _is_transient_ssh_error(exception: BaseException) -> bool: ) +def _is_transient_ssh_connect_error(exception: BaseException) -> bool: + """Check if the exception is a transient SSH connect failure worth retrying. + + Matches only pyinfra ``ConnectError``s wrapping paramiko's "Error reading + SSH protocol banner": the TCP connection was accepted but sshd did not + answer the SSH handshake in time, which happens transiently while a freshly + booted host's sshd is still coming up (e.g. a new Modal sandbox or VPS) or + while it is briefly overloaded. Refused/unreachable/auth/host-key failures + are deliberately not matched so genuinely-down hosts still fail fast. + """ + return isinstance(exception, ConnectError) and "error reading ssh protocol banner" in str(exception).lower() + + +_retry_on_transient_ssh_connect_error = retry( + retry=retry_if_exception(_is_transient_ssh_connect_error), + # One immediate retry, no pause: each failed attempt already blocked for + # paramiko's banner timeout (15s by default) waiting for sshd to answer, + # so two attempts bound the worst case (a host that accepts TCP but never + # speaks SSH) at ~30 seconds while still riding out the boot race. + stop=stop_after_attempt(2), + wait=wait_fixed(0), + reraise=True, +) + + def _get_ssh_transport(pyinfra_host: Any) -> Transport | None: """Extract the paramiko Transport from a pyinfra host, or None for non-SSH connectors.""" try: @@ -382,11 +419,23 @@ def _translate_ssh_errors(self, *, failed: str, closed: str, timed_out: str | No except (EOFError, SSHException) as e: raise HostConnectionError(failed) from e + @_retry_on_transient_ssh_connect_error + def _connect_with_transient_retry(self) -> None: + """Connect the pyinfra host, retrying banner-read connect failures. + + Each banner-read failure already spent paramiko's own banner timeout + waiting for sshd to answer, so a single immediate retry rides out the + boot race where a freshly created host accepts TCP before sshd is + ready -- which otherwise surfaces to users as a spurious create + failure -- while keeping the worst case bounded at ~30 seconds. + """ + self.connector.host.connect(raise_exceptions=True) + def _ensure_connected(self) -> None: """Ensure the pyinfra host is connected.""" try: if not self.connector.host.connected: - self.connector.host.connect(raise_exceptions=True) + self._connect_with_transient_retry() except ConnectError as e: message = str(e).lower() # Missing/unverifiable host keys are a trust failure: we have no basis to @@ -475,7 +524,7 @@ def _run_shell_command( ): return self._run_shell_command_with_transient_retry(command, pyinfra_kwargs) - @_retry_on_transient_ssh_error + @retry_on_transient_ssh_error def _run_shell_command_with_transient_retry( self, command: StringCommand, @@ -624,7 +673,7 @@ def _get_file( remote_filename, filename_or_io, remote_temp_filename, timeout_seconds ) - @_retry_on_transient_ssh_error + @retry_on_transient_ssh_error def _get_file_with_transient_retry( self, remote_filename: str, @@ -733,7 +782,7 @@ def _put_file( ): return self._put_file_with_transient_retry(filename_or_io, remote_filename, remote_temp_filename) - @_retry_on_transient_ssh_error + @retry_on_transient_ssh_error def _put_file_with_transient_retry( self, filename_or_io: str | IO[bytes], @@ -895,7 +944,7 @@ def _execute_streaming_local( success=(finished.returncode == 0), ) - @_retry_on_transient_ssh_error + @retry_on_transient_ssh_error def _execute_streaming_ssh_with_retry( self, command: str, @@ -1092,7 +1141,7 @@ def _list_directory_remote(self, path: Path, recursive: bool) -> list[VolumeFile ): return self._list_directory_remote_with_retry(path, recursive) - @_retry_on_transient_ssh_error + @retry_on_transient_ssh_error def _list_directory_remote_with_retry(self, path: Path, recursive: bool) -> list[VolumeFile]: self._ensure_connected() transport = self._get_paramiko_transport() diff --git a/vendor/mngr/libs/mngr/imbue/mngr/hosts/outer_host_test.py b/vendor/mngr/libs/mngr/imbue/mngr/hosts/outer_host_test.py index fca47e3d..81f5bdc2 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/hosts/outer_host_test.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/hosts/outer_host_test.py @@ -6,6 +6,7 @@ from typing import cast import pytest +from paramiko import ChannelException from paramiko import SSHException from pyinfra.api.exceptions import ConnectError from pyinfra.api.host import Host as PyinfraHost @@ -15,11 +16,12 @@ from imbue.mngr.errors import HostConnectionError from imbue.mngr.hosts.host import Host from imbue.mngr.hosts.outer_host import OuterHost -from imbue.mngr.hosts.outer_host import _is_transient_ssh_error +from imbue.mngr.hosts.outer_host import _is_transient_ssh_connect_error from imbue.mngr.hosts.outer_host import _prepend_env_exports from imbue.mngr.hosts.outer_host import _sftp_walk from imbue.mngr.hosts.outer_host import create_local_pyinfra_host from imbue.mngr.hosts.outer_host import create_ssh_pyinfra_host_using_user_config +from imbue.mngr.hosts.outer_host import is_transient_ssh_error from imbue.mngr.interfaces.data_types import FileType from imbue.mngr.interfaces.data_types import PyinfraConnector from imbue.mngr.interfaces.host import OuterHostInterface @@ -442,28 +444,150 @@ def test_ensure_connected_classifies_unrelated_connect_errors_as_connection_erro assert not isinstance(excinfo.value, HostAuthenticationError) +class _FakePyinfraHostRecoveringOnConnect: + """Pyinfra-host stand-in whose ``connect()`` fails a configured number of times, then succeeds. + + Just enough surface for ``OuterHost._ensure_connected`` to exercise its + transient-connect-failure retry without touching the network or paramiko. + """ + + def __init__(self, failure_count: int, message: str) -> None: + self.connected = False + self.name = "fake-ssh-host" + self.connector_cls = type("SSHConnector", (), {}) + self.connect_call_count = 0 + self._failure_count = failure_count + self._message = message + + def connect(self, raise_exceptions: bool = False) -> None: + self.connect_call_count += 1 + if self.connect_call_count <= self._failure_count: + raise ConnectError(self._message) + self.connected = True + + +def test_ensure_connected_retries_banner_read_connect_failures(temp_mngr_ctx: MngrContext) -> None: + """A banner-read ConnectError is retried, and the connect succeeds on the next attempt. + + Regression test for ``mngr create`` failing on freshly booted Modal + sandboxes/VPSs: the host accepts TCP before sshd answers the SSH + handshake, paramiko gives up with "Error reading SSH protocol banner", + and treating that first failed connect as fatal surfaced a spurious + "Create agent failed" (and a flaky ``test_snapshot_create_then_list_on_modal``). + """ + fake = _FakePyinfraHostRecoveringOnConnect( + failure_count=1, + message="SSH error (Error reading SSH protocol banner)", + ) + outer = OuterHost( + id=HostId.generate(), + connector=PyinfraConnector(cast(PyinfraHost, fake)), + mngr_ctx=temp_mngr_ctx, + ) + + outer._ensure_connected() + + assert fake.connected is True + assert fake.connect_call_count == 2 + + +def test_ensure_connected_gives_up_after_two_banner_read_attempts(temp_mngr_ctx: MngrContext) -> None: + """A persistent banner-read failure makes exactly two attempts before surfacing. + + Each attempt already blocks for paramiko's ~15s banner timeout, so capping + at two attempts bounds the worst case (a host that accepts TCP but never + speaks SSH) at ~30 seconds. + """ + fake = _FakePyinfraHostRecoveringOnConnect( + failure_count=5, + message="SSH error (Error reading SSH protocol banner)", + ) + outer = OuterHost( + id=HostId.generate(), + connector=PyinfraConnector(cast(PyinfraHost, fake)), + mngr_ctx=temp_mngr_ctx, + ) + + with pytest.raises(HostConnectionError): + outer._ensure_connected() + + assert fake.connect_call_count == 2 + + +def test_ensure_connected_does_not_retry_non_transient_connect_failures(temp_mngr_ctx: MngrContext) -> None: + """A refused connection is not retried: genuinely-down hosts must keep failing fast.""" + fake = _FakePyinfraHostRecoveringOnConnect( + failure_count=5, + message="Could not connect (Connection refused)", + ) + outer = OuterHost( + id=HostId.generate(), + connector=PyinfraConnector(cast(PyinfraHost, fake)), + mngr_ctx=temp_mngr_ctx, + ) + + with pytest.raises(HostConnectionError): + outer._ensure_connected() + + assert fake.connect_call_count == 1 + + +@pytest.mark.parametrize( + ("exception", "expected"), + [ + (ConnectError("SSH error (Error reading SSH protocol banner)"), True), + (ConnectError("Could not connect (Connection refused)"), False), + (ConnectError("Authentication error (username=alice): bad password"), False), + (SSHException("Error reading SSH protocol banner"), False), + ], + ids=["banner-read", "refused", "auth", "raw-ssh-exception"], +) +def test_is_transient_ssh_connect_error_matches_only_banner_read_connect_errors( + exception: BaseException, expected: bool +) -> None: + """Only pyinfra ``ConnectError``s wrapping paramiko's banner-read failure are transient. + + The raw ``SSHException`` case must stay False: at connect time pyinfra + always wraps it in ``ConnectError``, and mid-command banner problems are + handled by the separate ``is_transient_ssh_error`` classifier. + """ + assert _is_transient_ssh_connect_error(exception) is expected + + @pytest.mark.parametrize( ("exception", "expected"), [ (OSError("Socket is closed"), True), (OSError("No such file or directory"), False), + (ValueError("Socket is closed"), False), (SSHException("SSH session not active"), True), + (ChannelException(2, "open failed"), True), (EOFError(), True), (TimeoutError("Timed out reading output"), True), (ValueError("not transient"), False), ], - ids=["socket-closed", "other-os-error", "ssh-exception", "eof-error", "timeout-error", "non-os-error"], + ids=[ + "socket-closed", + "other-os-error", + "non-os-value-error", + "ssh-exception", + "channel-exception", + "eof-error", + "timeout-error", + "non-os-error", + ], ) -def test_is_transient_ssh_error_classifies_timeout_as_transient(exception: BaseException, expected: bool) -> None: - """Regression: ``TimeoutError`` from pyinfra's ``read_output_buffers`` must be classified transient. +def test_is_transient_ssh_error(exception: BaseException, expected: bool) -> None: + """The classifier accepts each transient SSH error kind and rejects everything else. - pyinfra raises a bare ``TimeoutError`` (Python builtin) when an SSH + The TimeoutError case is a regression guard: pyinfra raises a bare + ``TimeoutError`` (Python builtin) when an SSH command's response doesn't arrive within the per-command read timeout -- for example, when the remote sshd is reloaded mid-read during cloud-init. Without TimeoutError in the transient set, the retry loop didn't fire and the exception propagated all the way out of host creation. ``TimeoutError`` is an ``OSError`` subclass on - Python 3, so the classifier's ordering matters: the TimeoutError - branch must precede the narrow "Socket is closed" OSError check. + Python 3, but the classifier's OSError branch only matches on the + "Socket is closed" message, so bare timeouts need their own branch. """ - assert _is_transient_ssh_error(exception) is expected + assert is_transient_ssh_error(exception) is expected diff --git a/vendor/mngr/libs/mngr/imbue/mngr/interfaces/provider_instance.py b/vendor/mngr/libs/mngr/imbue/mngr/interfaces/provider_instance.py index 0b09c8b6..c409d38a 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/interfaces/provider_instance.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/interfaces/provider_instance.py @@ -582,6 +582,25 @@ def get_outer_ssh_port(self, host_id: HostId) -> int | None: """ return None + def get_container_loopback_ssh_port(self, host_id: HostId) -> int | None: + """Port at which the agent's container sshd is reachable from the *outer host's own loopback*. + + Distinct from the externally-routable port in ``get_ssh_connection_info``: + that one is how a remote client reaches the container (e.g. a slice's + box-forwarded port), whereas this is the port the container's sshd is + published on from the outer host's perspective (``127.0.0.1:`` on + the VPS/VM). The two coincide for a plain docker-on-VPS host but differ + for a slice, where the container is published in the VM on a fixed port + while reached from outside via a box-forwarded port. + + Returns ``None`` by default, meaning the externally-routable port is also + the outer-host-loopback port (callers fall back to that). Providers whose + topology splits publish from connect (e.g. imbue_cloud slices) override + this so a service running *on the outer host* (the VPS-resident latchkey + gateway) can reverse-tunnel into the container on the correct port. + """ + return None + def get_connection_error_fallback_state(self, host_id: HostId) -> HostState | None: """State to report for a host whose inner-SSH agent enumeration just failed. diff --git a/vendor/mngr/libs/mngr/imbue/mngr/primitives.py b/vendor/mngr/libs/mngr/imbue/mngr/primitives.py index 7f65895f..8630dbbc 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/primitives.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/primitives.py @@ -254,9 +254,11 @@ class HostState(UpperCaseStrEnum): FAILED = auto() DESTROYED = auto() UNAUTHENTICATED = auto() - # The provider that owns this host could not be accessed during the most recent discovery attempt, - # so the host's actual state is unknown. Distinct from None on HostDetails.state (which means - # "not observed / not applicable"). Emitted by AgentObserver when its provider errored. + # The host could not be observed during the most recent discovery attempt, so its actual state + # is unknown: either the provider that owns it could not be accessed (emitted by AgentObserver + # when its provider errored), or the provider was reachable but could not reach this specific + # host (e.g. imbue_cloud's lease-only fallback when a leased host's outer SSH is unreachable). + # Distinct from None on HostDetails.state (which means "not observed / not applicable"). UNKNOWN = auto() diff --git a/vendor/mngr/libs/mngr/imbue/mngr/providers/docker/instance.py b/vendor/mngr/libs/mngr/imbue/mngr/providers/docker/instance.py index fb159b9d..d6c5480a 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/providers/docker/instance.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/providers/docker/instance.py @@ -86,6 +86,7 @@ from imbue.mngr.providers.docker.volume import STATE_CONTAINER_TYPE_VALUE from imbue.mngr.providers.docker.volume import STATE_VOLUME_MOUNT_PATH from imbue.mngr.providers.docker.volume import ensure_state_container +from imbue.mngr.providers.docker.volume import host_container_name from imbue.mngr.providers.docker.volume import state_container_name from imbue.mngr.providers.docker.volume import state_volume_name from imbue.mngr.providers.ssh_host_setup import REQUIRED_HOST_PACKAGES @@ -1060,7 +1061,19 @@ def _find_container_by_host_id(self, host_id: HostId) -> docker.models.container return None def _find_container_by_name(self, name: HostName) -> docker.models.containers.Container | None: - """Find a Docker container by host_name label.""" + """Find this environment's Docker container for host ``name``. + + The host-name and provider labels carry no environment discriminator: + two mngr environments with different ``MNGR_PREFIX`` values (e.g. two + minds envs) both label their containers ``provider=docker`` + + ``host_name=``, so a label-only lookup can match another + environment's container. Require the container's actual name to be the + one ``host_container_name`` assigns, which scopes the match to this + environment (the same prefix filter ``_list_containers`` applies to + discovery). Containers are never renamed after creation -- ``mngr + rename`` updates only the host record -- so name and label stay in + lockstep. + """ try: containers = self._docker_client.containers.list( all=True, @@ -1069,7 +1082,11 @@ def _find_container_by_name(self, name: HostName) -> docker.models.containers.Co except docker.errors.DockerException as e: raise MngrError(f"Cannot connect to Docker daemon: {e}") from e - return containers[0] if containers else None + expected_container_name = host_container_name(self.mngr_ctx.config.prefix, name) + for container in containers: + if container.name == expected_container_name: + return container + return None def _list_containers(self) -> list[docker.models.containers.Container]: """List all Docker containers managed by this provider instance. @@ -1309,7 +1326,7 @@ def create_host( # Fail fast if a container with this name already exists, before the # expensive image build step. - container_name = f"{self.mngr_ctx.config.prefix}{name}" + container_name = host_container_name(self.mngr_ctx.config.prefix, name) existing = self._find_container_by_name(name) if existing is not None: raise MngrError( @@ -1628,7 +1645,7 @@ def _start_from_snapshot( logger.info("Restoring Docker container from snapshot", host_id=str(host_id), snapshot_id=str(snapshot_id)) labels = build_container_labels(host_id, host_name, str(self.name), user_tags) - container_name = f"{self.mngr_ctx.config.prefix}{host_name}" + container_name = host_container_name(self.mngr_ctx.config.prefix, host_name) effective_start_args = config.start_args @@ -1904,7 +1921,7 @@ def get_host( if host_record is not None: host_obj = self._create_host_from_host_record(host_record) else: - # Try container label lookup first (fast path) + # Try this environment's container for that host name first (fast path) container = self._find_container_by_name(host) if container is not None and self._is_container_running(container): host_obj = self._create_host_from_container(container) diff --git a/vendor/mngr/libs/mngr/imbue/mngr/providers/docker/test_docker_integration.py b/vendor/mngr/libs/mngr/imbue/mngr/providers/docker/test_docker_integration.py index cfb5fc27..f828842b 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/providers/docker/test_docker_integration.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/providers/docker/test_docker_integration.py @@ -30,6 +30,7 @@ from imbue.mngr.providers.docker.instance import LABEL_PROVIDER from imbue.mngr.providers.docker.instance import LABEL_TAGS from imbue.mngr.providers.docker.instance import build_container_labels +from imbue.mngr.providers.docker.volume import host_container_name from imbue.mngr.utils.testing import get_short_random_string pytestmark = [pytest.mark.acceptance] @@ -48,21 +49,31 @@ def _create_test_container( host_id: HostId | None = None, name: str = "test-host", tags: dict[str, str] | None = None, + container_name: str | None = None, ) -> tuple[docker.models.containers.Container, HostId]: """Create a bare container with labels (no SSH setup). - The container name uses the provider's MNGR prefix so that - ``_list_containers`` (which filters by prefix) can find it during - cleanup. + By default the container name uses the provider's MNGR prefix (plus a + random suffix, so unrelated tests never collide on Docker's name + uniqueness) and the ``docker_provider`` fixture's prefix-based teardown + cleans it up. + + Pass ``container_name`` to control the name exactly -- production + containers are always named ````, which the name-scoped + lookups depend on. A ``container_name`` outside the provider's prefix (e.g. + to stand in for another environment's container) is invisible to that + teardown, so the caller must remove such a container itself. """ if host_id is None: host_id = HostId.generate() labels = build_container_labels(host_id, HostName(name), str(provider.name), tags) prefix = provider.mngr_ctx.config.prefix - container_name = f"{prefix}integ-{get_short_random_string()}" + resolved_container_name = ( + container_name if container_name is not None else f"{prefix}integ-{get_short_random_string()}" + ) container = provider._docker_client.containers.run( image=TEST_IMAGE, - name=container_name, + name=resolved_container_name, command=CONTAINER_ENTRYPOINT, detach=True, labels=labels, @@ -125,11 +136,36 @@ def test_find_container_by_host_id_returns_none_for_unknown(docker_provider: Doc @pytest.mark.timeout(DOCKER_TEST_TIMEOUT) @pytest.mark.docker_sdk def test_find_container_by_name(docker_provider: DockerProviderInstance) -> None: - _create_test_container(docker_provider, name="discoverable") + # Production containers are always named by ``host_container_name``; the + # name-scoped lookup requires that shape, so mirror it here. + container_name = host_container_name(docker_provider.mngr_ctx.config.prefix, HostName("discoverable")) + _create_test_container(docker_provider, name="discoverable", container_name=container_name) found = docker_provider._find_container_by_name(HostName("discoverable")) assert found is not None +@pytest.mark.timeout(DOCKER_TEST_TIMEOUT) +@pytest.mark.docker_sdk +def test_find_container_by_name_ignores_other_environments(docker_provider: DockerProviderInstance) -> None: + """A container from another env (same labels, different MNGR prefix) must not match. + + The host-name/provider labels carry no environment discriminator, so two + environments that differ only in ``MNGR_PREFIX`` (e.g. two minds envs) + label their containers identically. Regression test for the create-time + collision check wrongly rejecting a host name that only existed in a + different environment's prefix namespace. + """ + other_env_container_name = f"other-env-{get_short_random_string()}-shared-name" + container, _ = _create_test_container(docker_provider, name="shared-name", container_name=other_env_container_name) + try: + found = docker_provider._find_container_by_name(HostName("shared-name")) + assert found is None + finally: + # The prefix-mismatched name means ``_list_containers`` cleanup skips + # this container; remove it explicitly. + container.remove(force=True) + + @pytest.mark.timeout(DOCKER_TEST_TIMEOUT) @pytest.mark.docker_sdk def test_list_containers_returns_managed_containers(docker_provider: DockerProviderInstance) -> None: diff --git a/vendor/mngr/libs/mngr/imbue/mngr/providers/docker/volume.py b/vendor/mngr/libs/mngr/imbue/mngr/providers/docker/volume.py index 21df9fe5..f566283b 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/providers/docker/volume.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/providers/docker/volume.py @@ -14,6 +14,7 @@ from imbue.mngr.interfaces.data_types import FileType from imbue.mngr.interfaces.data_types import VolumeFile from imbue.mngr.interfaces.volume import BaseVolume +from imbue.mngr.primitives import HostName # Docker label constants shared between volume.py and instance.py. # Defined here (the lower-level module) to avoid circular imports. @@ -31,6 +32,18 @@ STATE_VOLUME_MOUNT_PATH: Final[str] = "/mngr-state" +def host_container_name(prefix: str, host_name: HostName) -> str: + """Generate the name for the container backing host ``host_name``. + + Every creation path names a host's container this way, and lookups rely on + it: the labels alone carry no environment discriminator (two mngr + environments differing only in ``MNGR_PREFIX`` label their containers + identically), so the prefixed container name is what scopes a host name to + a single environment -- the same uniqueness scope Docker itself enforces. + """ + return f"{prefix}{host_name}" + + def state_container_name(prefix: str, user_id: str) -> str: """Generate the name for the singleton state container.""" return f"{prefix}docker-state-{user_id}" diff --git a/vendor/mngr/libs/mngr/imbue/mngr/resources/Dockerfile b/vendor/mngr/libs/mngr/imbue/mngr/resources/Dockerfile index 209a5935..3f3258ef 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/resources/Dockerfile +++ b/vendor/mngr/libs/mngr/imbue/mngr/resources/Dockerfile @@ -69,7 +69,7 @@ ENV PATH="/root/.local/bin:$PATH" # .mngr/settings.toml pin (`[agent_types.claude].version`). The release-test # `test_claude_code_version_matches_default_workspace_template_pin` enforces the # sync; bump both together when rolling a new claude release. -ARG CLAUDE_CODE_VERSION="2.1.160" +ARG CLAUDE_CODE_VERSION="2.1.207" RUN curl -fsSL https://claude.ai/install.sh > /tmp/install_claude.sh && ( if [ -n "$CLAUDE_CODE_VERSION" ]; then cat /tmp/install_claude.sh | bash -s "$CLAUDE_CODE_VERSION"; else cat /tmp/install_claude.sh | bash; fi && test -x /root/.local/bin/claude ) || ( cat /tmp/install_claude.sh && exit 1 ) ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} diff --git a/vendor/mngr/libs/mngr/imbue/mngr/utils/logging.py b/vendor/mngr/libs/mngr/imbue/mngr/utils/logging.py index 72ff11ee..23c47c01 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/utils/logging.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/utils/logging.py @@ -214,6 +214,29 @@ def emit(self, record: logging.LogRecord) -> None: logger.trace("[pyinfra] {}", msg) +# Logger-name glob patterns (matched by Sentry's ``ignore_logger`` via ``fnmatch``) for the +# noisy third-party stdlib loggers that ``suppress_warnings`` already redirects into loguru. +# +# Sentry's default ``LoggingIntegration`` patches ``logging.Logger.callHandlers`` at the class +# level, so it captures these records as Sentry *events* regardless of the ``propagate=False`` we +# set on the loggers below. That floods Sentry with already-handled connection-failure noise -- +# e.g. paramiko's transport thread logging ``Error reading SSH protocol banner`` (at ERROR, with a +# full traceback) every time a reverse-tunnel target goes offline and the health check retries it. +# Those events are not even rate-limited, because the stdlib records carry no ``exc_info`` and none +# of the loguru ``full_location`` fingerprint the rate limiter keys on. +# +# Passing these patterns to ``setup_sentry`` (which calls ``ignore_logger`` for each) tells Sentry +# to drop the raw records. Genuine, actionable failures still reach Sentry: they surface as typed +# exceptions (``HostConnectionError``, ``SSHTunnelError``, ...) logged through loguru at a higher +# level, which go through the loguru -> Sentry handler normally. +SENTRY_IGNORED_STDLIB_LOGGER_PATTERNS: Final[tuple[str, ...]] = ( + "paramiko", + "paramiko.*", + "pyinfra", + "pyinfra.*", +) + + _PARAMIKO_EXPECTED_ERROR_RE = re.compile( r"Exception \((?:client|server)\):" r"|Socket exception:" diff --git a/vendor/mngr/libs/mngr/imbue/mngr/utils/thread_cleanup_test.py b/vendor/mngr/libs/mngr/imbue/mngr/utils/thread_cleanup_test.py index cd664c3a..745d9fd7 100644 --- a/vendor/mngr/libs/mngr/imbue/mngr/utils/thread_cleanup_test.py +++ b/vendor/mngr/libs/mngr/imbue/mngr/utils/thread_cleanup_test.py @@ -9,6 +9,7 @@ import gc import gevent +import pytest from imbue.concurrency_group.concurrency_group import ConcurrencyGroup from imbue.mngr.utils.thread_cleanup import mngr_executor @@ -28,6 +29,13 @@ def _run_one_discovery_like_poll() -> None: future.result() +# The 30 executor spinups (each starting 4 worker threads) and the full-heap +# gc.collect()/gc.get_objects() probes take ~5s even on an idle machine, so on a +# contended runner this can cross the suite-wide 10s timeout. Give it headroom +# (a real leak still fails the growth assertion, just later) and let offload +# retry it as a known-flaky test. +@pytest.mark.flaky +@pytest.mark.timeout(30) def test_worker_hubs_do_not_accumulate_across_polls() -> None: """Each poll spins up worker threads that create gevent hubs; cleanup must free them so repeated polls do not strand a hub (and its retained object diff --git a/vendor/mngr/libs/mngr_claude/changelog/mngr-fable.md b/vendor/mngr/libs/mngr_claude/changelog/mngr-fable.md new file mode 100644 index 00000000..84d62d77 --- /dev/null +++ b/vendor/mngr/libs/mngr_claude/changelog/mngr-fable.md @@ -0,0 +1 @@ +Add `auto_disable_questions` to `ClaudeAgentConfig`: when True, appends `--disallowed-tools AskUserQuestion` to the agent invocation so unattended agents can't block on questions (recent Claude Code releases no longer treat AskUserQuestion as a permission prompt, so `auto_allow_permissions` alone no longer covers it). Appended as a separate flag; Claude accumulates repeated `--disallowed-tools` flags, so a list already supplied via `cli_args` is preserved. diff --git a/vendor/mngr/libs/mngr_claude/imbue/mngr_claude/plugin.py b/vendor/mngr/libs/mngr_claude/imbue/mngr_claude/plugin.py index 01a8d10a..0891d055 100644 --- a/vendor/mngr/libs/mngr_claude/imbue/mngr_claude/plugin.py +++ b/vendor/mngr/libs/mngr_claude/imbue/mngr_claude/plugin.py @@ -311,6 +311,11 @@ class ClaudeAgentConfig(AgentTypeConfig): description="When True, adds a PermissionRequest hook that auto-allows all permission dialogs. " "This means Claude Code will never pause for permission approval.", ) + auto_disable_questions: bool = Field( + default=False, + description="When True, adds `--disallowed-tools AskUserQuestion` to the agent invocation to " + "prevent it from ever asking questions (which can cause the agent to get blocked)", + ) settings_overrides: Annotated[dict[str, Any], SettingsPatchField()] = Field( default_factory=dict, description="A patch merged onto your home Claude settings at provisioning. A bare key assigns " @@ -2360,6 +2365,9 @@ def assemble_command( # (Claude is last-wins) -- the accepted, documented limitation of that mode. cli_args = self.agent_config.cli_args all_extra_args = cli_args + quote_agent_args(agent_args) + # Claude appends & unions repeated --disallowed-tools flags. + if self.agent_config.auto_disable_questions: + all_extra_args = all_extra_args + ("--disallowed-tools", "AskUserQuestion") args_str = " ".join(all_extra_args) if all_extra_args else "" # Read the latest session ID from the tracking file written by the SessionStart hook. diff --git a/vendor/mngr/libs/mngr_claude/imbue/mngr_claude/plugin_test.py b/vendor/mngr/libs/mngr_claude/imbue/mngr_claude/plugin_test.py index 2af64cd3..1c4f0c6f 100644 --- a/vendor/mngr/libs/mngr_claude/imbue/mngr_claude/plugin_test.py +++ b/vendor/mngr/libs/mngr_claude/imbue/mngr_claude/plugin_test.py @@ -493,6 +493,42 @@ def test_claude_agent_assemble_command_with_cli_args_and_agent_args( assert parsed.create_args == ["--verbose", "--model", "opus"] +def test_claude_agent_assemble_command_auto_disable_questions_appends_flag( + local_provider: LocalProviderInstance, tmp_path: Path, temp_mngr_ctx: MngrContext +) -> None: + """auto_disable_questions=True should append --disallowed-tools AskUserQuestion after all other args.""" + agent, host = make_claude_agent( + local_provider, + tmp_path, + temp_mngr_ctx, + agent_config=ClaudeAgentConfig( + cli_args=("--disallowed-tools", "TodoWrite"), + auto_disable_questions=True, + check_installation=False, + ), + ) + + command = agent.assemble_command(host=host, agent_args=(), command_override=None) + + parsed = _ParsedAssembleCommand(str(command)) + # Appended as a second --disallowed-tools flag (claude appends & unions + # repeated flags), so the cli_args-supplied list is preserved. + expected = ["--disallowed-tools", "TodoWrite", "--disallowed-tools", "AskUserQuestion"] + assert parsed.resume_args == expected + assert parsed.create_args == expected + + +def test_claude_agent_assemble_command_auto_disable_questions_off_by_default( + local_provider: LocalProviderInstance, tmp_path: Path, temp_mngr_ctx: MngrContext +) -> None: + """Default config should not add any --disallowed-tools flag.""" + agent, host = make_claude_agent(local_provider, tmp_path, temp_mngr_ctx) + + command = agent.assemble_command(host=host, agent_args=(), command_override=None) + + assert "--disallowed-tools" not in shlex.split(str(command)) + + def test_claude_agent_assemble_command_passes_user_settings_through( local_provider: LocalProviderInstance, tmp_path: Path, temp_mngr_ctx: MngrContext ) -> None: diff --git a/vendor/mngr/libs/mngr_forward/CHANGELOG.md b/vendor/mngr/libs/mngr_forward/CHANGELOG.md index 47c01d53..5b805c9f 100644 --- a/vendor/mngr/libs/mngr_forward/CHANGELOG.md +++ b/vendor/mngr/libs/mngr_forward/CHANGELOG.md @@ -8,6 +8,8 @@ For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDG ### Added +- Added: `mngr forward --use-http2` flag terminates TLS and negotiates HTTP/2 (via ALPN) for the workspace origin instead of plain HTTP/1.1, so the workspace UI is no longer capped by Chromium's ~6-connection-per-origin HTTP/1.1 limit. When set, the proxy generates a fresh self-signed cert at startup (SANs `localhost`, `*.localhost`, `127.0.0.1`; loaded via a transient 0600 temp file, never persisted), serves TLS + h2 via hypercorn (replacing uvicorn), and its client-facing URLs become `https`/`wss` with the `mngr_forward_session` cookie marked `Secure`. WebSockets (terminal, log stream, `/api/ws` state socket) keep working over the TLS connection. Off by default (a human running `mngr forward` still gets plain HTTP/1.1); there is no runtime HTTP fallback — if TLS setup fails the proxy exits during startup naming TLS/HTTP-2 as the cause. +- Added: `mngr forward` persists its per-agent service map to a small on-disk cache (`$MNGR_HOST_DIR/plugin/forward/service_map.json`) while running, and seeds the resolver from it at startup. A restored window onto a remote mind whose container is up becomes routable as soon as discovery supplies membership + SSH info, instead of waiting on the slow per-agent `mngr event ... services` stream (measured ~10s cold, ~50s under spawn contention). The live stream still runs and overwrites the seed as soon as it delivers, so a stale seed self-corrects within one stream connect. An empty, missing, or corrupt cache is a no-op. - Added: `mngr forward --on-error {abort,continue}` flag (default `abort`). Under `continue`, the `--no-observe` startup snapshot tolerates an unauthenticated/unreachable provider (runs `mngr list --on-error continue` and forwards the agents the healthy providers reported instead of failing to start). ### Changed diff --git a/vendor/mngr/libs/mngr_forward/UNABRIDGED_CHANGELOG.md b/vendor/mngr/libs/mngr_forward/UNABRIDGED_CHANGELOG.md index 964f0a9a..85b707b6 100644 --- a/vendor/mngr/libs/mngr_forward/UNABRIDGED_CHANGELOG.md +++ b/vendor/mngr/libs/mngr_forward/UNABRIDGED_CHANGELOG.md @@ -4,6 +4,16 @@ Full, unedited changelog entries consolidated nightly from individual files in ` For a concise summary, see [CHANGELOG.md](CHANGELOG.md). +## 2026-07-13 + +Add a `--use-http2` flag to `mngr forward` that terminates TLS and negotiates HTTP/2 (via ALPN) for the workspace origin instead of serving plain HTTP/1.1. + +HTTP/2 multiplexes many streams over one connection, so the workspace UI is no longer capped by Chromium's ~6-connection-per-origin HTTP/1.1 limit (which hangs the UI once enough chat/app streams are open). When the flag is set, the proxy generates a fresh self-signed cert at startup (SANs `localhost`, `*.localhost`, `127.0.0.1`; loaded via a transient 0600 temp file, never persisted), serves TLS + h2 via hypercorn (replacing uvicorn), and its client-facing URLs become `https`/`wss` with the `mngr_forward_session` cookie marked `Secure`. WebSockets (terminal, log stream, `/api/ws` state socket) keep working over the TLS connection. hypercorn is the ASGI server whether or not a WebSocket rides the h2 connection (it advertises RFC 8441 Extended CONNECT), and its `websocket.receive` events always include both `text` and `bytes` keys with the unused one `None` (uvicorn omitted it), so the client->backend forwarder now selects each frame's payload by value rather than key presence. + +The flag is off by default, so a human running `mngr forward` still gets plain HTTP/1.1 with no cert friction; only clients that can trust the self-signed cert (the minds desktop app) should enable it. There is no runtime HTTP fallback -- if TLS setup fails the proxy exits during startup with a log line naming TLS/HTTP-2 as the cause. + +`mngr forward` now persists its per-agent service map to a small on-disk cache (`$MNGR_HOST_DIR/plugin/forward/service_map.json`) while running, and seeds the resolver from it at startup. A restored window onto a remote mind whose container is up becomes routable as soon as discovery supplies membership + SSH info, instead of waiting on the slow per-agent `mngr event ... services` stream (measured ~10s cold, ~50s under spawn contention). The live stream still runs and overwrites the seed as soon as it delivers, so a stale seed self-corrects within one stream connect. An empty, missing, or corrupt cache is a no-op: startup behaves exactly as before. + ## 2026-07-10 The forward stream manager no longer logs a "Discovery error from ..." warning on every poll cycle for a provider stuck on the same failure (e.g. missing credentials): provider-level discovery errors are now logged once per process via the shared `DiscoveryErrorLogSuppressor`, with an info-level recovery line (and re-armed suppression) when the provider's discovery next succeeds. Host- and agent-attributed discovery errors keep logging on every occurrence. diff --git a/vendor/mngr/libs/mngr_forward/changelog/mngr-fix-http-2-noise.md b/vendor/mngr/libs/mngr_forward/changelog/mngr-fix-http-2-noise.md new file mode 100644 index 00000000..95b603f5 --- /dev/null +++ b/vendor/mngr/libs/mngr_forward/changelog/mngr-fix-http-2-noise.md @@ -0,0 +1 @@ +`mngr forward --use-http2`: abandoned TLS connections no longer dump an "Unhandled exception in client_connected_cb / TimeoutError: SSL shutdown timed out" traceback to stderr, and are now force-closed after a bounded few-second wait instead of holding a per-connection task open for asyncio's full 30-second SSL shutdown timeout. The serve loop bounds the wait for the peer's close_notify reply (hypercorn does not expose asyncio's ssl_shutdown_timeout) and drops the benign teardown errors of already-dead connections (the SSL-shutdown TimeoutError and TLS handshake failures) instead of logging them as unhandled exceptions. diff --git a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/cli.py b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/cli.py index aa9dd6a8..47d12360 100644 --- a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/cli.py +++ b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/cli.py @@ -1,23 +1,30 @@ """Click entry point for ``mngr forward``.""" +import asyncio +import os import secrets import signal import socket +import ssl import subprocess import threading import time import webbrowser +from collections.abc import Awaitable +from collections.abc import Callable from pathlib import Path from typing import Any from typing import Final import click -import uvicorn +from hypercorn.asyncio import serve as hypercorn_serve +from hypercorn.config import Config from loguru import logger from imbue.concurrency_group.concurrency_group import ConcurrencyGroup from imbue.imbue_common.primitives import NonNegativeInt from imbue.imbue_common.primitives import PositiveInt +from imbue.imbue_common.pure import pure from imbue.mngr.api.discovery_events import get_discovery_events_path from imbue.mngr.cli.common_opts import add_common_options from imbue.mngr.cli.common_opts import setup_command_context @@ -42,13 +49,18 @@ from imbue.mngr_forward.resolver import ForwardResolver from imbue.mngr_forward.reverse_handler import ReverseTunnelHandler from imbue.mngr_forward.server import create_forward_app +from imbue.mngr_forward.service_map_cache import ServiceMapCache from imbue.mngr_forward.snapshot import mngr_list_snapshot from imbue.mngr_forward.ssh_tunnel import SSHTunnelManager from imbue.mngr_forward.stream_manager import ForwardStreamManager +from imbue.mngr_forward.tls import InMemoryTLSConfig +from imbue.mngr_forward.tls import build_server_ssl_context +from imbue.mngr_forward.tls import generate_self_signed_cert _DEFAULT_HOST: Final[str] = "127.0.0.1" _DEFAULT_PORT: Final[int] = 8421 _OTP_LENGTH: Final[int] = 32 +_SERVICE_MAP_CACHE_FILENAME: Final[str] = "service_map.json" class ForwardCliOptions(CommonCliOptions): @@ -69,6 +81,7 @@ class ForwardCliOptions(CommonCliOptions): preauth_cookie: str | None = None open_browser: bool = False allow_host_loopback: bool = False + use_http2: bool = False def _parse_reverse_specs(raw: tuple[str, ...]) -> tuple[ReverseTunnelSpec, ...]: @@ -110,8 +123,8 @@ def _bind_listen_socket(host: str, requested_port: int | None) -> socket.socket: ``click.ClickException`` is raised -- a caller that picked a specific port did so deliberately, so silently moving would hide a real conflict. - The returned socket is bound but not listening; uvicorn calls ``listen()`` - on it via ``loop.create_server``. + The returned socket is bound but not listening; hypercorn calls + ``listen()`` on it via ``asyncio.start_server`` after the fd handoff. """ family = socket.AF_INET6 if ":" in host else socket.AF_INET sock = socket.socket(family=family) @@ -235,6 +248,17 @@ def _bind_listen_socket(host: str, requested_port: int | None) -> socket.socket: "the host. Pass this flag only for setups that intentionally run agents directly on the host." ), ) +@click.option( + "--use-http2", + is_flag=True, + default=False, + help=( + "Terminate TLS and negotiate HTTP/2 (via ALPN) instead of serving plain HTTP/1.1. " + "Removes Chromium's ~6-connection-per-origin ceiling for the workspace UI. The proxy " + "generates a fresh self-signed cert at startup, so only clients that trust it (the minds " + "desktop app) should enable this; a human browser will see a cert warning." + ), +) @add_common_options @click.pass_context def forward(ctx: click.Context, **kwargs: Any) -> None: @@ -260,8 +284,15 @@ def forward(ctx: click.Context, **kwargs: Any) -> None: envelope_writer = EnvelopeWriter() + plugin_state_dir = _resolve_plugin_state_dir(_resolve_mngr_host_dir(mngr_ctx)) + service_map_cache = ServiceMapCache(cache_path=plugin_state_dir / _SERVICE_MAP_CACHE_FILENAME) + strategy = _build_strategy(opts) - resolver = ForwardResolver(strategy=strategy, envelope_writer=envelope_writer) + resolver = ForwardResolver( + strategy=strategy, + envelope_writer=envelope_writer, + service_map_cache=service_map_cache, + ) tunnel_manager = SSHTunnelManager() reverse_specs = _parse_reverse_specs(opts.reverse) @@ -281,6 +312,12 @@ def forward(ctx: click.Context, **kwargs: Any) -> None: del kept # used internally by the helper stream_manager: ForwardStreamManager | None = None else: + # Seed the resolver's service map from the previous run's cache so a + # restored window resolves as soon as discovery supplies membership + + # SSH info, instead of waiting on the slow per-agent event stream. The + # live stream still runs and overwrites the seed as it delivers; an + # empty/absent cache is a no-op (today's behavior). + resolver.seed_services(service_map_cache.load()) discovery_events_path = get_discovery_events_path(mngr_ctx.config) if opts.observe_via_file else None stream_manager = ForwardStreamManager( resolver=resolver, @@ -298,13 +335,13 @@ def forward(ctx: click.Context, **kwargs: Any) -> None: if reverse_specs: tunnel_manager.start_reverse_tunnel_health_check() - plugin_state_dir = _resolve_plugin_state_dir(_resolve_mngr_host_dir(mngr_ctx)) auth_store = FileAuthStore(data_directory=plugin_state_dir) one_time_code = OneTimeCode(secrets.token_urlsafe(_OTP_LENGTH)) auth_store.add_one_time_code(code=one_time_code) login_host = "localhost" if opts.host in {"127.0.0.1", "0.0.0.0", "::1", "::"} else opts.host - login_url = f"http://{login_host}:{listen_port}/login?one_time_code={one_time_code}" + scheme = "https" if opts.use_http2 else "http" + login_url = f"{scheme}://{login_host}:{listen_port}/login?one_time_code={one_time_code}" logger.info("Login URL (one-time use): {}", login_url) envelope_writer.emit_login_url(login_url) @@ -332,14 +369,11 @@ def _on_listening() -> None: preauth_cookie_value=opts.preauth_cookie, on_listening=_on_listening, allow_host_loopback=opts.allow_host_loopback, + use_http2=opts.use_http2, ) try: - # Hand uvicorn the socket we already bound rather than a host/port - # pair, so there is no window between resolving the port and the - # server claiming it (and no chance uvicorn binds a different one). - server = uvicorn.Server(uvicorn.Config(app, timeout_graceful_shutdown=1, log_level="warning")) - server.run(sockets=[listen_socket]) + _serve_forward_app(app, listen_socket, use_http2=opts.use_http2) finally: listen_socket.close() if stream_manager is not None: @@ -348,6 +382,135 @@ def _on_listening() -> None: envelope_writer.close() +def _build_hypercorn_config(listen_socket: socket.socket, use_http2: bool) -> Config: + """Build the hypercorn ``Config`` for serving over the already-bound socket. + + When ``use_http2`` is set the config carries an in-memory TLS context + (HTTP/2 negotiated via ALPN); otherwise it is plain HTTP/1.1, matching the + previous uvicorn behaviour. The bound-but-not-listening socket is handed off + by file descriptor (``fd://``): hypercorn's ``asyncio.start_server`` performs + the ``listen()``, so there is no window where a different server could claim + the port. hypercorn closes the fd it wraps on shutdown, so we hand it a + ``os.dup`` of the socket's fd and let the caller's ``finally`` close the + original -- avoiding a double close. + """ + config: Config + if use_http2: + try: + cert_pem, key_pem = generate_self_signed_cert() + ssl_context = build_server_ssl_context(cert_pem, key_pem) + except (ValueError, ssl.SSLError, OSError) as e: + # Naming TLS/HTTP-2 setup explicitly here makes the failure + # diagnosable: minds' `wait_for_listening` will otherwise just time + # out with no indication that the cert/context was the cause. + logger.error("mngr forward TLS/HTTP-2 setup failed (cert generation or SSL context): {}", e) + raise + config = InMemoryTLSConfig(ssl_context) + else: + config = Config() + + dup_fd = os.dup(listen_socket.fileno()) + config.bind = [f"fd://{dup_fd}"] + # Match the previous uvicorn settings: 1s graceful drain and warning-level + # logging (so hypercorn's per-connection "Running on ..." info line is + # suppressed). + config.graceful_timeout = 1.0 + config.loglevel = "WARNING" + return config + + +# How long a TLS teardown may wait for the peer's close_notify reply before +# the connection is force-closed. asyncio's default (30s) keeps every +# abandoned connection's task alive for the full wait; a loopback peer that +# has not answered within a few seconds is gone. +_SSL_SHUTDOWN_TIMEOUT_SECONDS: Final[float] = 5.0 + +# The exact message asyncio's sslproto puts on the TimeoutError it raises when +# the close_notify reply never arrives (stable since Python 3.11). +_SSL_SHUTDOWN_TIMED_OUT_MESSAGE: Final[str] = "SSL shutdown timed out" + + +class _BoundedSSLShutdownEventLoop(asyncio.SelectorEventLoop): + """Event loop that bounds server-side TLS shutdown waits to a few seconds. + + hypercorn's ``asyncio.start_server`` call does not expose asyncio's + ``ssl_shutdown_timeout`` option, so the only seam for shortening the 30s + default is the loop's SSL transport factory. ``_make_ssl_transport`` is + private CPython API: the override therefore only rewrites the stdlib's + "use the default" sentinel (``None``) when that keyword is actually + present, so a future signature change degrades to the stdlib default + instead of breaking connection accepts. + """ + + ssl_shutdown_timeout_seconds: float = _SSL_SHUTDOWN_TIMEOUT_SECONDS + + def _make_ssl_transport(self, *args: Any, **kwargs: Any) -> Any: + if "ssl_shutdown_timeout" in kwargs and kwargs["ssl_shutdown_timeout"] is None: + kwargs["ssl_shutdown_timeout"] = self.ssl_shutdown_timeout_seconds + return super()._make_ssl_transport(*args, **kwargs) # ty: ignore[unresolved-attribute] + + +@pure +def _is_benign_tls_teardown_error(exception: BaseException | None) -> bool: + """True for per-connection TLS noise that should not reach the log as an error. + + Covers ``ssl.SSLError`` (handshake failures -- hypercorn's own runner drops + these, and we replace its exception handler by running the loop ourselves) + and the ``TimeoutError`` asyncio raises when a peer abandons a connection + without answering ``close_notify``. hypercorn's ``TCPServer._close`` + catches the other already-gone connection errors but not that + ``TimeoutError``, so it would otherwise escape ``client_connected_cb`` and + be logged as an alarming unhandled-exception traceback. + """ + is_handshake_failure = isinstance(exception, ssl.SSLError) + is_abandoned_shutdown = isinstance(exception, TimeoutError) and _SSL_SHUTDOWN_TIMED_OUT_MESSAGE in str(exception) + return is_handshake_failure or is_abandoned_shutdown + + +def _handle_serve_loop_exception(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None: + """Loop exception handler: drop benign TLS teardown noise, defer the rest.""" + exception = context.get("exception") + if _is_benign_tls_teardown_error(exception): + logger.debug("Dropped benign TLS teardown error from an abandoned connection: {!r}", exception) + return + loop.default_exception_handler(context) + + +def _run_serve_loop( + app: Any, + config: Config, + loop_factory: Callable[[], asyncio.AbstractEventLoop], + shutdown_trigger: Callable[..., Awaitable[Any]] | None, +) -> None: + """Run hypercorn on a loop from ``loop_factory``, dropping benign TLS teardown noise. + + ``shutdown_trigger=None`` makes hypercorn install its own SIGINT/SIGTERM + handlers (which requires running on the main thread); tests pass an + explicit trigger instead so they can stop the server from another thread. + """ + with asyncio.Runner(loop_factory=loop_factory) as runner: + runner.get_loop().set_exception_handler(_handle_serve_loop_exception) + runner.run(hypercorn_serve(app, config, shutdown_trigger=shutdown_trigger)) + + +def _serve_forward_app(app: Any, listen_socket: socket.socket, use_http2: bool) -> None: + """Serve the forward app over the already-bound ``listen_socket`` via hypercorn. + + Passes ``shutdown_trigger=None``, which makes hypercorn install its own + SIGINT/SIGTERM handlers -- matching the SIGTERM->graceful behaviour + minds' ``terminate()`` relies on. Must run on the main thread so the loop can + install those signal handlers. + + The loop carries two TLS teardown adjustments hypercorn does not expose + (both matter only with ``--use-http2``): the SSL shutdown wait is bounded to + seconds instead of asyncio's 30s default, and the benign teardown errors of + abandoned connections are dropped rather than logged as unhandled-exception + tracebacks. + """ + config = _build_hypercorn_config(listen_socket, use_http2) + _run_serve_loop(app, config, loop_factory=_BoundedSSLShutdownEventLoop, shutdown_trigger=None) + + def _validate_options(opts: ForwardCliOptions) -> None: if opts.service is None and opts.forward_port is None: raise click.UsageError("Exactly one of --service NAME or --forward-port REMOTE_PORT is required.") diff --git a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/cli_test.py b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/cli_test.py index 11fb0a04..350e48e2 100644 --- a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/cli_test.py +++ b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/cli_test.py @@ -1,23 +1,44 @@ -"""Tests for ``mngr forward``'s CLI option validation. +"""Tests for ``mngr forward``'s CLI helpers. -These tests stub out heavy dependencies (the FastAPI app + uvicorn loop) -by inspecting only the option-validation phase via direct calls to the -helpers. End-to-end CLI invocation is exercised by the acceptance test. +Covers option validation (direct calls to the validation helpers, without +building the FastAPI app or a CLI process) and the hypercorn serving layer's +TLS teardown behavior (serve-loop exception handling plus an end-to-end +abandoned-connection repro over a loopback socket). End-to-end CLI invocation +is exercised by the acceptance test. """ +# asyncio is normally banned, but this file tests the event-loop-level TLS +# teardown behavior of `mngr forward`'s hypercorn serving path, which can only +# be exercised from inside an asyncio loop. +import asyncio +import os import socket +import ssl +import threading import click import pytest +from hypercorn.config import Config +from hypercorn.typing import ASGIReceiveCallable +from hypercorn.typing import ASGISendCallable +from hypercorn.typing import Scope +from loguru import logger from imbue.imbue_common.primitives import NonNegativeInt from imbue.imbue_common.primitives import PositiveInt +from imbue.mngr.utils.polling import poll_for_value +from imbue.mngr.utils.polling import wait_for from imbue.mngr_forward.cli import ForwardCliOptions +from imbue.mngr_forward.cli import _BoundedSSLShutdownEventLoop from imbue.mngr_forward.cli import _DEFAULT_PORT +from imbue.mngr_forward.cli import _SSL_SHUTDOWN_TIMED_OUT_MESSAGE from imbue.mngr_forward.cli import _bind_listen_socket +from imbue.mngr_forward.cli import _build_hypercorn_config from imbue.mngr_forward.cli import _build_strategy from imbue.mngr_forward.cli import _filter_snapshot +from imbue.mngr_forward.cli import _handle_serve_loop_exception from imbue.mngr_forward.cli import _parse_reverse_specs +from imbue.mngr_forward.cli import _run_serve_loop from imbue.mngr_forward.cli import _validate_options from imbue.mngr_forward.data_types import ForwardAgentSnapshot from imbue.mngr_forward.data_types import ForwardListSnapshot @@ -26,6 +47,7 @@ from imbue.mngr_forward.primitives import ReverseTunnelSpec from imbue.mngr_forward.testing import TEST_AGENT_ID_1 from imbue.mngr_forward.testing import TEST_AGENT_ID_2 +from imbue.mngr_forward.tls import InMemoryTLSConfig def _opts(**overrides: object) -> ForwardCliOptions: @@ -160,6 +182,51 @@ def test_bind_listen_socket_falls_back_when_default_port_taken() -> None: sock.close() +def _fd_from_bind(config: Config) -> int: + """Parse the fd number out of a ``fd://`` bind entry.""" + assert len(config.bind) == 1 + bind = config.bind[0] + assert bind.startswith("fd://") + return int(bind[len("fd://") :]) + + +def test_build_hypercorn_config_plain_http_when_flag_off() -> None: + """Flag off yields a plain ``Config`` with no TLS, handed the socket by fd.""" + sock = _bind_listen_socket("127.0.0.1", None) + try: + config = _build_hypercorn_config(sock, use_http2=False) + dup_fd = _fd_from_bind(config) + try: + assert not isinstance(config, InMemoryTLSConfig) + assert config.ssl_enabled is False + assert config.graceful_timeout == 1.0 + # The fd handed to hypercorn is a dup, not the original -- so + # hypercorn closing it on shutdown does not double-close the + # socket the caller's ``finally`` also closes. + assert dup_fd != sock.fileno() + finally: + os.close(dup_fd) + finally: + sock.close() + + +def test_build_hypercorn_config_enables_tls_when_flag_on() -> None: + """Flag on yields an ``InMemoryTLSConfig`` whose context is a real SSLContext.""" + sock = _bind_listen_socket("127.0.0.1", None) + try: + config = _build_hypercorn_config(sock, use_http2=True) + dup_fd = _fd_from_bind(config) + try: + assert isinstance(config, InMemoryTLSConfig) + assert config.ssl_enabled is True + assert isinstance(config.create_ssl_context(), ssl.SSLContext) + assert dup_fd != sock.fileno() + finally: + os.close(dup_fd) + finally: + sock.close() + + def test_filter_snapshot_supports_provider_name_filter() -> None: """`--agent-include` / `--agent-exclude` must work the same in --no-observe mode @@ -189,3 +256,148 @@ def test_filter_snapshot_supports_host_id_and_name_filter() -> None: assert tuple(entry.agent_id for entry in by_host.agents) == (TEST_AGENT_ID_1,) by_name = _filter_snapshot(snapshot, include=(), exclude=("agent.name == 'alpha'",)) assert tuple(entry.agent_id for entry in by_name.agents) == (TEST_AGENT_ID_2,) + + +def _asyncio_error_records(caplog: pytest.LogCaptureFixture) -> list[str]: + return [record.getMessage() for record in caplog.records if record.name == "asyncio"] + + +def _serve_loop_asyncio_error_records( + caplog: pytest.LogCaptureFixture, message: str, exception: BaseException +) -> list[str]: + """Run the serve-loop exception handler on a fresh loop; return asyncio ERROR records.""" + loop = asyncio.new_event_loop() + try: + with caplog.at_level("ERROR", logger="asyncio"): + _handle_serve_loop_exception(loop, {"message": message, "exception": exception}) + finally: + loop.close() + return _asyncio_error_records(caplog) + + +def test_serve_loop_exception_handler_drops_ssl_shutdown_timeout(caplog: pytest.LogCaptureFixture) -> None: + """The TimeoutError of an abandoned TLS teardown must not reach asyncio's default handler.""" + records = _serve_loop_asyncio_error_records( + caplog, "Unhandled exception in client_connected_cb", TimeoutError("SSL shutdown timed out") + ) + assert records == [] + + +def test_serve_loop_exception_handler_drops_ssl_errors(caplog: pytest.LogCaptureFixture) -> None: + """TLS handshake failures are dropped, matching hypercorn's own runner behavior.""" + records = _serve_loop_asyncio_error_records( + caplog, "SSL handshake failed", ssl.SSLError(1, "TLSV1_ALERT_UNKNOWN_CA") + ) + assert records == [] + + +def test_serve_loop_exception_handler_delegates_unrelated_errors(caplog: pytest.LogCaptureFixture) -> None: + """Anything that is not benign TLS teardown noise still reaches the default handler.""" + records = _serve_loop_asyncio_error_records(caplog, "something exploded in a task", RuntimeError("kaboom-7c1f")) + assert any("something exploded in a task" in message for message in records) + + +def test_serve_loop_exception_handler_delegates_other_timeouts(caplog: pytest.LogCaptureFixture) -> None: + """Only the SSL-shutdown TimeoutError is suppressed; other timeouts must stay visible.""" + records = _serve_loop_asyncio_error_records(caplog, "some other timeout", TimeoutError("read timed out")) + assert any("some other timeout" in message for message in records) + + +class _FastSSLShutdownEventLoop(_BoundedSSLShutdownEventLoop): + """Serve loop with a sub-second TLS shutdown bound so the test stays fast.""" + + ssl_shutdown_timeout_seconds: float = 0.4 + + +async def _lifespan_only_asgi_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: + """Minimal ASGI app for serving-layer tests; no HTTP request is ever made.""" + assert scope["type"] == "lifespan" + await receive() + await send({"type": "lifespan.startup.complete"}) + await receive() + await send({"type": "lifespan.shutdown.complete"}) + + +def _run_tls_server_until_stopped(config: Config, stop_serving: threading.Event) -> None: + """Serve the dummy app through the production serve loop until the event is set.""" + + async def _stop_trigger() -> None: + while not stop_serving.is_set(): + await asyncio.sleep(0.05) + + _run_serve_loop( + _lifespan_only_asgi_app, config, loop_factory=_FastSSLShutdownEventLoop, shutdown_trigger=_stop_trigger + ) + + +def _connect_tls_client_when_listening(port: int) -> ssl.SSLSocket: + """Open a TLS client connection (cert checks off), retrying until the server listens.""" + client_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + client_context.check_hostname = False + client_context.verify_mode = ssl.CERT_NONE + + def _try_connect() -> ssl.SSLSocket | None: + try: + raw_socket = socket.create_connection(("127.0.0.1", port), timeout=5.0) + except ConnectionRefusedError: + return None + return client_context.wrap_socket(raw_socket, server_hostname="localhost") + + tls_socket, _, _ = poll_for_value(_try_connect, timeout=10.0, poll_interval=0.05) + assert tls_socket is not None, "the TLS server never started listening" + return tls_socket + + +def test_abandoned_tls_connection_is_torn_down_quickly_and_quietly(caplog: pytest.LogCaptureFixture) -> None: + """End-to-end repro of the --use-http2 teardown noise (GitHub issue 2455). + + A TLS client completes a handshake and then goes silent, never answering + the server's close_notify. The serve loop must force-close the connection + within the bounded SSL shutdown wait (not asyncio's 30s default), and the + escaping TimeoutError must be dropped instead of surfacing as an + "Unhandled exception in client_connected_cb" traceback. + """ + listen_socket = _bind_listen_socket("127.0.0.1", 0) + listen_port = listen_socket.getsockname()[1] + config = _build_hypercorn_config(listen_socket, use_http2=True) + # Shrink the keep-alive so the server initiates the close (and thereby the + # TLS shutdown) shortly after the client goes idle. + config.keep_alive_timeout = 0.25 + + stop_serving = threading.Event() + suppressed_messages: list[str] = [] + sink_id = logger.add(suppressed_messages.append, level="DEBUG") + server_thread = threading.Thread( + target=_run_tls_server_until_stopped, + args=(config, stop_serving), + name="forward-tls-teardown-test-server", + daemon=True, + ) + + def _is_teardown_suppressed() -> bool: + return any(_SSL_SHUTDOWN_TIMED_OUT_MESSAGE in message for message in tuple(suppressed_messages)) + + with caplog.at_level("ERROR", logger="asyncio"): + server_thread.start() + try: + client_socket = _connect_tls_client_when_listening(listen_port) + try: + # If the 30s stdlib shutdown timeout were still in effect, or + # the teardown error were not routed through the suppression + # path, this wait would time out. + wait_for( + condition=_is_teardown_suppressed, + timeout=10.0, + poll_interval=0.05, + error_message="the abandoned TLS connection was not torn down within the bounded wait", + ) + finally: + client_socket.close() + finally: + stop_serving.set() + server_thread.join(timeout=10.0) + logger.remove(sink_id) + listen_socket.close() + + assert not server_thread.is_alive() + assert _asyncio_error_records(caplog) == [] diff --git a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/resolver.py b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/resolver.py index 2a5adc0b..5cec8aeb 100644 --- a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/resolver.py +++ b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/resolver.py @@ -30,6 +30,7 @@ from imbue.mngr_forward.data_types import ForwardStrategy from imbue.mngr_forward.data_types import ProxyTarget from imbue.mngr_forward.envelope import EnvelopeWriter +from imbue.mngr_forward.service_map_cache import ServiceMapCache from imbue.mngr_forward.ssh_tunnel import RemoteSSHInfo @@ -51,6 +52,16 @@ class ForwardResolver(MutableModel): "service map. None in tests / code paths that don't care about emission." ), ) + service_map_cache: ServiceMapCache | None = Field( + default=None, + description=( + "Optional last-known service-map cache. When set, every mutation of " + "the per-agent services map (the same points that emit " + "``resolver_snapshot``) is persisted through it, and ``seed_services`` " + "loads from it at startup so a fresh run resolves without waiting on " + "the slow per-agent event stream. None in tests / paths that don't persist." + ), + ) _lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) _services_by_agent: dict[str, dict[str, str]] = PrivateAttr(default_factory=dict) @@ -88,8 +99,8 @@ def update_known_agents(self, agent_ids: tuple[AgentId, ...]) -> None: self._initial_discovery_done = True if services_changed: snapshot = self._snapshot_services_locked() - if snapshot is not None and self.envelope_writer is not None: - self.envelope_writer.emit_resolver_snapshot(snapshot) + if snapshot is not None: + self._publish_services_snapshot(snapshot) def add_known_agent(self, agent_id: AgentId) -> None: """Mark a single agent as known (incremental discovery).""" @@ -115,8 +126,8 @@ def remove_known_agent(self, agent_id: AgentId) -> None: self._ssh_by_agent.pop(aid_str, None) if services_changed: snapshot = self._snapshot_services_locked() - if snapshot is not None and self.envelope_writer is not None: - self.envelope_writer.emit_resolver_snapshot(snapshot) + if snapshot is not None: + self._publish_services_snapshot(snapshot) def update_services(self, agent_id: AgentId, services: dict[str, str]) -> None: """Replace the known services for a single agent. @@ -129,8 +140,33 @@ def update_services(self, agent_id: AgentId, services: dict[str, str]) -> None: with self._lock: self._services_by_agent[str(agent_id)] = dict(services) snapshot = self._snapshot_services_locked() + self._publish_services_snapshot(snapshot) + + def seed_services(self, services_by_agent: dict[str, dict[str, str]]) -> None: + """Seed the per-agent service map from a last-known cache at startup. + + Fills only the services map; ``resolve()`` still gates on + discovery-supplied membership, so a seeded entry is served only once + this run's discovery confirms the agent is known. Does not emit or + re-persist -- it loads what is already on disk. The resolver is empty at + startup, so in practice this is a plain fill. + """ + with self._lock: + for aid_str, services in services_by_agent.items(): + self._services_by_agent[aid_str] = dict(services) + + def _publish_services_snapshot(self, snapshot: dict[str, dict[str, str]]) -> None: + """Emit the ``resolver_snapshot`` envelope and persist the service-map cache. + + Called (outside ``self._lock``) at every point that mutates the + per-agent services map. The envelope keeps a downstream consumer's + mirror in sync; the cache persists the same full map so a later run can + seed from it. + """ if self.envelope_writer is not None: self.envelope_writer.emit_resolver_snapshot(snapshot) + if self.service_map_cache is not None: + self.service_map_cache.persist(snapshot) def update_ssh_info(self, agent_id: AgentId, ssh_info: RemoteSSHInfo) -> None: """Set or replace the SSH info for a single agent.""" diff --git a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/resolver_test.py b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/resolver_test.py index ea795c0a..caa55356 100644 --- a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/resolver_test.py +++ b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/resolver_test.py @@ -9,6 +9,7 @@ from imbue.mngr_forward.data_types import ForwardServiceStrategy from imbue.mngr_forward.envelope import EnvelopeWriter from imbue.mngr_forward.resolver import ForwardResolver +from imbue.mngr_forward.service_map_cache import ServiceMapCache from imbue.mngr_forward.ssh_tunnel import RemoteSSHInfo from imbue.mngr_forward.testing import TEST_AGENT_ID_1 from imbue.mngr_forward.testing import TEST_AGENT_ID_2 @@ -202,3 +203,76 @@ def test_initial_discovery_flag() -> None: assert resolver.has_completed_initial_discovery() is False resolver.update_known_agents(()) assert resolver.has_completed_initial_discovery() is True + + +# --- last-known service-map cache (fast first-load) ----------------------- + + +def test_seeded_entry_not_served_until_agent_is_known() -> None: + """A seeded service URL is not routable until discovery confirms the agent. + + This is the safety property that makes seeding a stale cache acceptable: + ``resolve`` gates on this run's known-agent set, so a cache entry for an + agent this run does not discover is never served. + """ + resolver = ForwardResolver(strategy=ForwardServiceStrategy(service_name="system_interface")) + resolver.seed_services({str(TEST_AGENT_ID_1): {"system_interface": "http://127.0.0.1:8000"}}) + assert resolver.resolve(TEST_AGENT_ID_1) is None + resolver.add_known_agent(TEST_AGENT_ID_1) + target = resolver.resolve(TEST_AGENT_ID_1) + assert target is not None + assert str(target.url).rstrip("/") == "http://127.0.0.1:8000" + + +def test_live_update_overwrites_seeded_service_entry() -> None: + """The live event stream's full-replace corrects a stale seed.""" + resolver = ForwardResolver(strategy=ForwardServiceStrategy(service_name="system_interface")) + resolver.add_known_agent(TEST_AGENT_ID_1) + resolver.seed_services({str(TEST_AGENT_ID_1): {"system_interface": "http://127.0.0.1:8000"}}) + resolver.update_services(TEST_AGENT_ID_1, {"system_interface": "http://127.0.0.1:9999"}) + target = resolver.resolve(TEST_AGENT_ID_1) + assert target is not None + assert str(target.url).rstrip("/") == "http://127.0.0.1:9999" + + +def test_update_services_persists_to_cache(tmp_path: Path) -> None: + cache = ServiceMapCache(cache_path=tmp_path / "service_map.json") + resolver = ForwardResolver( + strategy=ForwardServiceStrategy(service_name="system_interface"), + service_map_cache=cache, + ) + resolver.add_known_agent(TEST_AGENT_ID_1) + resolver.update_services(TEST_AGENT_ID_1, {"system_interface": "http://127.0.0.1:8000"}) + assert cache.load() == {str(TEST_AGENT_ID_1): {"system_interface": "http://127.0.0.1:8000"}} + + +def test_remove_known_agent_drops_cache_entry(tmp_path: Path) -> None: + cache = ServiceMapCache(cache_path=tmp_path / "service_map.json") + resolver = ForwardResolver( + strategy=ForwardServiceStrategy(service_name="system_interface"), + service_map_cache=cache, + ) + resolver.add_known_agent(TEST_AGENT_ID_1) + resolver.update_services(TEST_AGENT_ID_1, {"system_interface": "http://127.0.0.1:8000"}) + resolver.remove_known_agent(TEST_AGENT_ID_1) + assert cache.load() == {} + + +def test_persisted_map_seeds_a_fresh_resolver(tmp_path: Path) -> None: + """End-to-end: one run persists its service map; a fresh run seeds from it and resolves.""" + cache = ServiceMapCache(cache_path=tmp_path / "service_map.json") + first_run = ForwardResolver( + strategy=ForwardServiceStrategy(service_name="system_interface"), + service_map_cache=cache, + ) + first_run.add_known_agent(TEST_AGENT_ID_1) + first_run.update_services(TEST_AGENT_ID_1, {"system_interface": "http://127.0.0.1:8000"}) + + fresh_run = ForwardResolver(strategy=ForwardServiceStrategy(service_name="system_interface")) + fresh_run.seed_services(cache.load()) + # Still gated on discovery: not served until this run marks the agent known. + assert fresh_run.resolve(TEST_AGENT_ID_1) is None + fresh_run.add_known_agent(TEST_AGENT_ID_1) + target = fresh_run.resolve(TEST_AGENT_ID_1) + assert target is not None + assert str(target.url).rstrip("/") == "http://127.0.0.1:8000" diff --git a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/server.py b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/server.py index 2cd4111d..2f6539fb 100644 --- a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/server.py +++ b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/server.py @@ -161,7 +161,7 @@ def _parse_workspace_subdomain(host_header: str) -> AgentId | None: return None -def _unauthenticated_subdomain_response(request: Request, port: int) -> Response: +def _unauthenticated_subdomain_response(request: Request, port: int, use_http2: bool) -> Response: """Redirect HTML navigations to the agent's /goto/ bridge; 403 for everything else. The bridge re-mints a fresh subdomain auth token using the bare-origin @@ -178,12 +178,13 @@ def _unauthenticated_subdomain_response(request: Request, port: int) -> Response accept = request.headers.get("accept", "") if "text/html" not in accept: return Response(status_code=403, content="Not authenticated") + scheme = "https" if use_http2 else "http" host_header = request.headers.get("host", "") agent_id = _parse_workspace_subdomain(host_header) if agent_id is None: - location = f"http://localhost:{port}/" + location = f"{scheme}://localhost:{port}/" else: - location = f"http://localhost:{port}/goto/{agent_id}/" + location = f"{scheme}://localhost:{port}/goto/{agent_id}/" return Response(status_code=302, headers={"Location": location}) @@ -208,6 +209,23 @@ def _connect_backend_websocket( return websockets.connect(ws_url, subprotocols=ws_subprotocols) +def _select_ws_receive_payload(event: Mapping[str, Any]) -> str | bytes | None: + """Return the payload of an ASGI ``websocket.receive`` event (text or bytes), or None. + + Per the ASGI spec exactly one of ``text``/``bytes`` is non-None. We select by + value rather than key presence because hypercorn always includes BOTH keys + (setting the unused one to None), so a key-presence check would pick the + co-present ``text: None`` on a binary frame and forward ``None`` -- which + raises ``TypeError`` in the ``websockets`` client and kills the socket + (uvicorn omitted the unused key, which masked this). Returns None for an + event carrying neither payload, which the caller skips. + """ + text = event.get("text") + if text is not None: + return text + return event.get("bytes") + + async def _forward_client_to_backend( client_websocket: WebSocket, backend_ws: ClientConnection, @@ -218,10 +236,9 @@ async def _forward_client_to_backend( msg_type = data.get("type", "") if msg_type == "websocket.disconnect": break - if "text" in data: - await backend_ws.send(data["text"]) - elif "bytes" in data: - await backend_ws.send(data["bytes"]) + payload = _select_ws_receive_payload(data) + if payload is not None: + await backend_ws.send(payload) except WebSocketDisconnect: logger.trace("Client WebSocket disconnected") except RuntimeError as e: @@ -530,6 +547,7 @@ def _handle_subdomain_auth_bridge( request: Request, agent_id: AgentId, auth_store: AuthStoreInterface, + use_http2: bool, ) -> Response: token = request.query_params.get("token", "") next_url = _sanitize_next_url(request.query_params.get("next", "/")) @@ -544,6 +562,7 @@ def _handle_subdomain_auth_bridge( path="/", httponly=True, samesite="lax", + secure=use_http2, ) return response @@ -560,6 +579,7 @@ async def _handle_workspace_forward_http( listen_port: int, allow_host_loopback: bool, envelope_writer: EnvelopeWriter, + use_http2: bool, ) -> Response: host_header = request.headers.get("host", "") agent_id = _parse_workspace_subdomain(host_header) @@ -567,14 +587,14 @@ async def _handle_workspace_forward_http( return Response(status_code=404) if request.url.path == _SUBDOMAIN_AUTH_PATH: - return _handle_subdomain_auth_bridge(request, agent_id, auth_store) + return _handle_subdomain_auth_bridge(request, agent_id, auth_store, use_http2) if not _is_authenticated( cookies=request.cookies, auth_store=auth_store, preauth_cookie_value=preauth_cookie_value, ): - return _unauthenticated_subdomain_response(request, listen_port) + return _unauthenticated_subdomain_response(request, listen_port, use_http2) target = resolver.resolve(agent_id) if target is None: @@ -757,6 +777,7 @@ def _handle_authenticate( one_time_code: str, auth_store: AuthStoreInterface, env: Environment, + use_http2: bool, ) -> Response: if not one_time_code or not one_time_code.strip(): html = _render_auth_error_page(env, message="This login code is invalid or has already been used.") @@ -775,6 +796,7 @@ def _handle_authenticate( path="/", httponly=True, samesite="lax", + secure=use_http2, ) return response @@ -817,6 +839,7 @@ def _handle_goto_workspace( auth_store: AuthStoreInterface, preauth_cookie_value: str | None, listen_port: int, + use_http2: bool, ) -> Response: if not _is_authenticated( cookies=request.cookies, @@ -832,7 +855,10 @@ def _handle_goto_workspace( token = create_subdomain_auth_token(signing_key=signing_key, agent_id=str(parsed_id)) next_url = _sanitize_next_url(request.query_params.get("next", "/")) encoded_next = quote(next_url, safe="") - location = f"http://{parsed_id}.localhost:{listen_port}{_SUBDOMAIN_AUTH_PATH}?token={token}&next={encoded_next}" + scheme = "https" if use_http2 else "http" + location = ( + f"{scheme}://{parsed_id}.localhost:{listen_port}{_SUBDOMAIN_AUTH_PATH}?token={token}&next={encoded_next}" + ) return Response(status_code=302, headers={"Location": location}) @@ -882,6 +908,7 @@ def create_forward_app( preauth_cookie_value: str | None = None, on_listening: Callable[[], None] | None = None, allow_host_loopback: bool = False, + use_http2: bool = False, ) -> FastAPI: """Create the FastAPI app for ``mngr forward``. @@ -892,6 +919,12 @@ def create_forward_app( bound on the host's loopback at the registered port. Pass ``True`` only for setups that intentionally run agents directly on the host (the legacy ``LaunchMode.DEV`` flow). + + ``use_http2`` reflects whether the server terminates TLS (and negotiates + HTTP/2); when set, the client-facing URLs this app constructs use + ``https``/``wss`` and its session cookie is marked ``Secure``. It does not + itself enable TLS -- the serve path does -- but the two must agree so the + URLs the browser is told to visit match the scheme the socket speaks. """ env = _build_jinja_env() @@ -907,6 +940,7 @@ def create_forward_app( app.state.listen_port = listen_port app.state.preauth_cookie_value = preauth_cookie_value app.state.allow_host_loopback = allow_host_loopback + app.state.use_http2 = use_http2 @app.middleware("http") async def _subdomain_routing_middleware(request: Request, call_next: Any) -> Response: @@ -926,6 +960,7 @@ async def _subdomain_routing_middleware(request: Request, call_next: Any) -> Res listen_port=listen_port, allow_host_loopback=allow_host_loopback, envelope_writer=envelope_writer, + use_http2=use_http2, ) @app.get("/login") @@ -944,6 +979,7 @@ def _authenticate(one_time_code: str) -> Response: one_time_code=one_time_code, auth_store=auth_store, env=env, + use_http2=use_http2, ) @app.get("/") @@ -966,6 +1002,7 @@ def _goto(agent_id: str, request: Request) -> Response: auth_store=auth_store, preauth_cookie_value=preauth_cookie_value, listen_port=listen_port, + use_http2=use_http2, ) @app.websocket("/{path:path}") diff --git a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/server_test.py b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/server_test.py index edb8a8d3..c2cbf1a2 100644 --- a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/server_test.py +++ b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/server_test.py @@ -27,6 +27,7 @@ from imbue.mngr_forward.resolver import ForwardResolver from imbue.mngr_forward.server import _is_loopback_url from imbue.mngr_forward.server import _sanitize_next_url +from imbue.mngr_forward.server import _select_ws_receive_payload from imbue.mngr_forward.server import create_forward_app from imbue.mngr_forward.ssh_tunnel import RemoteSSHInfo from imbue.mngr_forward.ssh_tunnel import SSHTunnelError @@ -51,6 +52,29 @@ def app_setup(tmp_path: Path) -> tuple[TestClient, FileAuthStore, ForwardResolve return client, auth_store, resolver +@pytest.fixture +def http2_app_setup(tmp_path: Path) -> tuple[TestClient, FileAuthStore, ForwardResolver]: + """Same as ``app_setup`` but with ``use_http2=True`` so client-facing URLs + + become ``https``/``wss`` and the session cookie is marked ``Secure``. + """ + auth_store = FileAuthStore(data_directory=tmp_path) + resolver = ForwardResolver(strategy=ForwardServiceStrategy(service_name="system_interface")) + tunnel_manager = SSHTunnelManager() + envelope_writer = EnvelopeWriter(output=io.StringIO()) + app = create_forward_app( + auth_store=auth_store, + resolver=resolver, + tunnel_manager=tunnel_manager, + envelope_writer=envelope_writer, + listen_host="127.0.0.1", + listen_port=18421, + use_http2=True, + ) + client = TestClient(app, follow_redirects=False) + return client, auth_store, resolver + + def test_bare_origin_unauthenticated_returns_login_page( app_setup: tuple[TestClient, FileAuthStore, ForwardResolver], ) -> None: @@ -87,6 +111,109 @@ def test_authenticate_consumes_otp_and_sets_cookie( assert response2.status_code == 403 +def test_authenticate_cookie_not_secure_without_http2( + app_setup: tuple[TestClient, FileAuthStore, ForwardResolver], +) -> None: + """With the flag off the session cookie must NOT be Secure (plain http origin).""" + client, store, _resolver = app_setup + code = OneTimeCode("no-http2-cookie-1") + store.add_one_time_code(code=code) + response = client.get(f"/authenticate?one_time_code={code}") + assert response.status_code == 307 + set_cookie = response.headers["set-cookie"] + assert MNGR_FORWARD_SESSION_COOKIE_NAME in set_cookie + assert "secure" not in set_cookie.lower() + + +def test_http2_authenticate_sets_secure_cookie( + http2_app_setup: tuple[TestClient, FileAuthStore, ForwardResolver], +) -> None: + """With ``use_http2`` on, the session cookie set by /authenticate is Secure.""" + client, store, _resolver = http2_app_setup + code = OneTimeCode("http2-cookie-1") + store.add_one_time_code(code=code) + response = client.get(f"/authenticate?one_time_code={code}") + assert response.status_code == 307 + set_cookie = response.headers["set-cookie"] + assert MNGR_FORWARD_SESSION_COOKIE_NAME in set_cookie + assert "secure" in set_cookie.lower() + + +def test_http2_goto_authenticated_redirects_to_https_subdomain( + http2_app_setup: tuple[TestClient, FileAuthStore, ForwardResolver], +) -> None: + """With ``use_http2`` on, the /goto bridge sends the browser to an https subdomain URL.""" + client, store, _resolver = http2_app_setup + cookie = create_session_cookie(store.get_signing_key()) + valid_agent_id = "agent-" + "0" * 31 + "a" + response = client.get( + f"/goto/{valid_agent_id}/", + cookies={MNGR_FORWARD_SESSION_COOKIE_NAME: cookie}, + ) + assert response.status_code == 302 + location = response.headers["location"] + assert location.startswith(f"https://{valid_agent_id}.localhost:18421/_subdomain_auth?token=") + + +def test_http2_subdomain_unauthenticated_html_redirects_to_https_goto(tmp_path: Path) -> None: + """With ``use_http2`` on, a stale-cookie subdomain HTML load redirects to the https /goto bridge.""" + auth_store = FileAuthStore(data_directory=tmp_path) + resolver = ForwardResolver(strategy=ForwardServiceStrategy(service_name="system_interface")) + agent_id = AgentId() + resolver.add_known_agent(agent_id) + resolver.update_services(agent_id, {"system_interface": "http://stub-backend"}) + tunnel_manager = SSHTunnelManager() + envelope_writer = EnvelopeWriter(output=io.StringIO()) + listen_port = 18421 + app = create_forward_app( + auth_store=auth_store, + resolver=resolver, + tunnel_manager=tunnel_manager, + envelope_writer=envelope_writer, + listen_host="127.0.0.1", + listen_port=listen_port, + use_http2=True, + ) + with TestClient(app, base_url=f"https://{agent_id}.localhost:{listen_port}", follow_redirects=False) as client: + response = client.get( + "/", + headers={ + "accept": "text/html", + "cookie": f"{MNGR_FORWARD_SESSION_COOKIE_NAME}=stale-cookie-from-previous-launch", + }, + ) + assert response.status_code == 302 + assert response.headers["Location"] == f"https://localhost:{listen_port}/goto/{agent_id}/" + + +def test_http2_subdomain_auth_bridge_sets_secure_cookie(tmp_path: Path) -> None: + """With ``use_http2`` on, the /_subdomain_auth bridge sets a Secure session cookie.""" + auth_store = FileAuthStore(data_directory=tmp_path) + resolver = ForwardResolver(strategy=ForwardServiceStrategy(service_name="system_interface")) + tunnel_manager = SSHTunnelManager() + envelope_writer = EnvelopeWriter(output=io.StringIO()) + app = create_forward_app( + auth_store=auth_store, + resolver=resolver, + tunnel_manager=tunnel_manager, + envelope_writer=envelope_writer, + listen_host="127.0.0.1", + listen_port=18421, + use_http2=True, + ) + valid_agent_id = "agent-" + "0" * 31 + "a" + token = create_subdomain_auth_token(signing_key=auth_store.get_signing_key(), agent_id=valid_agent_id) + with TestClient(app, follow_redirects=False) as client: + response = client.get( + f"/_subdomain_auth?token={token}&next=/", + headers={"host": f"{valid_agent_id}.localhost:18421"}, + ) + assert response.status_code == 302 + set_cookie = response.headers["set-cookie"] + assert MNGR_FORWARD_SESSION_COOKIE_NAME in set_cookie + assert "secure" in set_cookie.lower() + + def test_invalid_otp_returns_403( app_setup: tuple[TestClient, FileAuthStore, ForwardResolver], ) -> None: @@ -1101,3 +1228,19 @@ def test_subdomain_forward_websocket_emits_failure_on_ssh_tunnel_setup_error(tmp payload = envelope["payload"] assert payload["type"] == "system_interface_backend_failure" assert payload["reason"] == "CONNECT_ERROR" + + +def test_select_ws_receive_payload_selects_by_value_not_key() -> None: + """A binary frame must yield its bytes, not the co-present ``text: None``. + + hypercorn emits every ``websocket.receive`` event with BOTH ``text`` and + ``bytes`` keys, setting the unused one to ``None`` (uvicorn omitted it). A + key-presence check would pick ``text=None`` on a binary frame and forward + ``None``, which raises ``TypeError`` in the ``websockets`` client and kills + the terminal / state sockets the workspace SPA relies on. Selecting by value + fixes it; an event with neither payload yields ``None`` (caller skips it). + """ + assert _select_ws_receive_payload({"type": "websocket.receive", "bytes": None, "text": "hello"}) == "hello" + # The regression case: a binary frame carries text=None alongside its bytes. + assert _select_ws_receive_payload({"type": "websocket.receive", "bytes": b"world", "text": None}) == b"world" + assert _select_ws_receive_payload({"type": "websocket.receive", "bytes": None, "text": None}) is None diff --git a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/service_map_cache.py b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/service_map_cache.py new file mode 100644 index 00000000..e5883177 --- /dev/null +++ b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/service_map_cache.py @@ -0,0 +1,77 @@ +"""Last-known per-agent service map, persisted across ``mngr forward`` runs. + +The resolver's per-agent ``{service -> url}`` map is only populated after the +slow per-agent ``mngr event ... services --follow`` stream connects (measured +~10s cold, longer under spawn contention). Until then ``resolve()`` returns +``None`` and the proxy serves the 503 loading page. + +This cache persists that derived map to disk while the plugin runs, so a fresh +run can seed the resolver from it at startup: a restored window then resolves +as soon as discovery supplies membership + SSH info instead of waiting on the +event stream. The live stream still runs and overwrites the seed as soon as it +delivers, so a stale seed self-corrects within one stream connect. + +The cache is a single JSON object mapping agent id -> {service_name -> url}. +It lives under the caller-chosen path (the plugin points it at +``$MNGR_HOST_DIR/plugin/forward/``), so staging / production / local minds keep +independent caches automatically. +""" + +import json +from pathlib import Path + +from loguru import logger +from pydantic import Field + +from imbue.imbue_common.frozen_model import FrozenModel +from imbue.mngr.utils.file_utils import atomic_write +from imbue.mngr.utils.file_utils import read_json_dict + + +class ServiceMapCache(FrozenModel): + """Reads and writes the last-known per-agent service map at ``cache_path``.""" + + cache_path: Path = Field(frozen=True, description="JSON file holding the persisted service map") + + def load(self) -> dict[str, dict[str, str]]: + """Return the persisted ``{agent_id -> {service -> url}}`` map. + + A missing, empty, unreadable, or malformed cache file yields ``{}`` -- + seeding from it is then a no-op and startup behaves exactly as if no + cache existed. Only well-formed ``str -> {str -> str}`` entries are + kept; anything else is dropped so a corrupt file can never inject a bad + route. + """ + try: + raw = read_json_dict(self.cache_path) + except (OSError, UnicodeDecodeError) as e: + # OSError: unreadable file (permissions, IO). UnicodeDecodeError: + # non-UTF-8 bytes, which read_json_dict does not catch. Either way + # the file is unusable, so degrade to {} instead of breaking startup. + logger.warning("Could not read forward service-map cache {} ({}); ignoring.", self.cache_path, e) + return {} + return _coerce_service_map(raw) + + def persist(self, services_by_agent: dict[str, dict[str, str]]) -> None: + """Atomically write the full service map to disk (best effort). + + Called on every mutation of the resolver's service map. A write + failure is logged and swallowed: the cache is an optimization, and a + failed persist must never break forwarding. + """ + try: + atomic_write(self.cache_path, json.dumps(services_by_agent, sort_keys=True)) + except OSError as e: + logger.warning("Could not persist forward service-map cache {} ({}); continuing.", self.cache_path, e) + + +def _coerce_service_map(raw: dict[str, object]) -> dict[str, dict[str, str]]: + """Keep only well-formed ``str -> {str -> str}`` entries from parsed JSON.""" + result: dict[str, dict[str, str]] = {} + for agent_id, services in raw.items(): + if not isinstance(services, dict): + continue + clean = {name: url for name, url in services.items() if isinstance(name, str) and isinstance(url, str)} + if clean: + result[agent_id] = clean + return result diff --git a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/service_map_cache_test.py b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/service_map_cache_test.py new file mode 100644 index 00000000..9eb46053 --- /dev/null +++ b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/service_map_cache_test.py @@ -0,0 +1,91 @@ +import os +from pathlib import Path + +import pytest + +from imbue.mngr_forward.service_map_cache import ServiceMapCache + + +def test_load_missing_file_returns_empty(tmp_path: Path) -> None: + cache = ServiceMapCache(cache_path=tmp_path / "service_map.json") + assert cache.load() == {} + + +def test_persist_then_load_roundtrip(tmp_path: Path) -> None: + cache = ServiceMapCache(cache_path=tmp_path / "service_map.json") + payload = { + "agent-a": {"system_interface": "http://127.0.0.1:8000", "web": "http://127.0.0.1:8080"}, + "agent-b": {"system_interface": "http://127.0.0.1:8000"}, + } + cache.persist(payload) + assert cache.load() == payload + + +def test_persist_overwrites_previous_contents(tmp_path: Path) -> None: + cache = ServiceMapCache(cache_path=tmp_path / "service_map.json") + cache.persist({"agent-a": {"system_interface": "http://127.0.0.1:8000"}}) + cache.persist({"agent-b": {"system_interface": "http://127.0.0.1:9000"}}) + assert cache.load() == {"agent-b": {"system_interface": "http://127.0.0.1:9000"}} + + +def test_load_corrupt_json_returns_empty(tmp_path: Path) -> None: + cache_path = tmp_path / "service_map.json" + cache_path.write_text("{not valid json") + assert ServiceMapCache(cache_path=cache_path).load() == {} + + +def test_load_invalid_utf8_returns_empty(tmp_path: Path) -> None: + # A cache file with non-UTF-8 bytes is malformed; load must degrade to {} + # rather than leaking UnicodeDecodeError, since load runs on the forward + # startup critical path via resolver.seed_services. + cache_path = tmp_path / "service_map.json" + cache_path.write_bytes(b"\xff\xfe garbage") + assert ServiceMapCache(cache_path=cache_path).load() == {} + + +def test_load_non_object_json_returns_empty(tmp_path: Path) -> None: + cache_path = tmp_path / "service_map.json" + cache_path.write_text('["a", "b"]') + assert ServiceMapCache(cache_path=cache_path).load() == {} + + +def test_load_drops_malformed_entries(tmp_path: Path) -> None: + cache_path = tmp_path / "service_map.json" + # A mix of a valid entry, a non-dict value, a dict with a non-string URL, + # and a dict that becomes empty after cleaning. Only the valid entry survives. + cache_path.write_text( + '{"agent-good": {"system_interface": "http://127.0.0.1:8000"}, ' + '"agent-bad-value": "not-a-dict", ' + '"agent-bad-url": {"system_interface": 8000}, ' + '"agent-empty": {"system_interface": null}}' + ) + assert ServiceMapCache(cache_path=cache_path).load() == { + "agent-good": {"system_interface": "http://127.0.0.1:8000"} + } + + +@pytest.mark.skipif( + hasattr(os, "geteuid") and os.geteuid() == 0, + reason="root bypasses file permission checks, so the file stays readable", +) +def test_load_swallows_read_error(tmp_path: Path) -> None: + # An existing-but-unreadable cache file must degrade to {} rather than + # raising, so a permission/IO error can never break forward startup + # (load runs on the startup critical path via resolver.seed_services). + cache_path = tmp_path / "service_map.json" + cache_path.write_text('{"agent-a": {"system_interface": "http://127.0.0.1:8000"}}') + cache_path.chmod(0o000) + try: + assert ServiceMapCache(cache_path=cache_path).load() == {} + finally: + cache_path.chmod(0o600) + + +def test_persist_swallows_write_error(tmp_path: Path) -> None: + # Point the cache at a path whose parent is a regular file, so the atomic + # write's mkdir fails. persist must log and swallow rather than raise. + blocker = tmp_path / "blocker" + blocker.write_text("i am a file, not a directory") + cache = ServiceMapCache(cache_path=blocker / "service_map.json") + cache.persist({"agent-a": {"system_interface": "http://127.0.0.1:8000"}}) + assert cache.load() == {} diff --git a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/test_ratchets.py b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/test_ratchets.py index a4dd6b96..8611cdb8 100644 --- a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/test_ratchets.py +++ b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/test_ratchets.py @@ -95,7 +95,14 @@ def test_prevent_setattr() -> None: def test_prevent_asyncio_import() -> None: - rc.check_asyncio_import(_DIR, snapshot(1)) + # 3: server.py has always been asyncio (the proxy is an async ASGI app), and + # cli.py now does `asyncio.run(hypercorn.asyncio.serve(...))` to run that app + # in-process -- the necessary replacement for uvicorn's sync `.run()` (which + # itself ran an asyncio loop). Hypercorn exposes no non-asyncio serve path + # for an in-process app object. cli_test.py exercises the serve loop's TLS + # teardown behavior (bounded SSL shutdown + exception handler), which can + # only be tested from inside an asyncio loop. + rc.check_asyncio_import(_DIR, snapshot(3)) def test_prevent_pandas_import() -> None: @@ -123,7 +130,11 @@ def test_prevent_exit_stack() -> None: def test_prevent_async_await() -> None: - rc.check_async_await(_DIR, snapshot(42)) + # 48: the six additions over the historical 42 are all in cli_test.py's + # hypercorn serving-path tests (a minimal lifespan-only ASGI app and a + # shutdown trigger), which necessarily run inside the asyncio loop under + # test. + rc.check_async_await(_DIR, snapshot(48)) # --- Hardcoded paths --- @@ -260,7 +271,12 @@ def test_prevent_underscore_imports() -> None: def test_prevent_init_methods_in_non_exception_classes() -> None: - rc.check_init_methods_in_non_exception_classes(_DIR, snapshot(1)) + # 2: InMemoryTLSConfig subclasses hypercorn's third-party `Config` (a plain, + # non-model class) and needs `__init__` to call super().__init__() and hold + # the per-instance in-memory SSLContext. It cannot be a pydantic model, and + # setting the context from outside the class would evade this ratchet while + # doing the same thing, so the __init__ stays. + rc.check_init_methods_in_non_exception_classes(_DIR, snapshot(2)) def test_prevent_cast_usage() -> None: diff --git a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/tls.py b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/tls.py new file mode 100644 index 00000000..73eaf942 --- /dev/null +++ b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/tls.py @@ -0,0 +1,114 @@ +"""Ephemeral in-memory TLS for ``mngr forward`` (self-signed cert + hypercorn config). + +Used only when ``--use-http2`` is set: the proxy terminates TLS and negotiates +HTTP/2 (via ALPN), which multiplexes many streams over a single connection. +The certificate is self-signed, regenerated every startup, and covers only +loopback names (``localhost``, ``*.localhost``, ``127.0.0.1``), so it is +trusted only by clients that opt in -- no OS trust store or CA install is +involved. +""" + +import ipaddress +import os +import ssl +import tempfile +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from typing import Final + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from hypercorn.config import Config + +# HTTP/2 first, HTTP/1.1 fallback. WebSocket upgrades negotiate http/1.1 on +# their own connection via this same list; h2 carries the plain HTTP requests +# that were the constrained resource. +ALPN_PROTOCOLS: Final[list[str]] = ["h2", "http/1.1"] + +_CERT_VALIDITY_DAYS: Final[int] = 3650 +_RSA_KEY_SIZE: Final[int] = 2048 + + +def generate_self_signed_cert() -> tuple[bytes, bytes]: + """Return ``(cert_pem, key_pem)`` for a fresh self-signed loopback cert. + + The SANs cover ``localhost``, ``*.localhost`` (the ``agent-.localhost`` + workspace subdomains -- the wildcard does not match the bare label, so both + are required), and ``127.0.0.1`` (loopback probes that dial the IP host). + """ + key = rsa.generate_private_key(public_exponent=65537, key_size=_RSA_KEY_SIZE) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + now = datetime.now(timezone.utc) + subject_alt_name = x509.SubjectAlternativeName( + [ + x509.DNSName("localhost"), + x509.DNSName("*.localhost"), + x509.IPAddress(ipaddress.ip_address("127.0.0.1")), + ] + ) + certificate = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + timedelta(days=_CERT_VALIDITY_DAYS)) + .add_extension(subject_alt_name, critical=False) + .sign(private_key=key, algorithm=hashes.SHA256()) + ) + cert_pem = certificate.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + return cert_pem, key_pem + + +def build_server_ssl_context(cert_pem: bytes, key_pem: bytes) -> ssl.SSLContext: + """Build a server ``SSLContext`` (ALPN h2/http1.1, TLS >= 1.2) from in-memory PEM. + + Python's ``SSLContext.load_cert_chain`` only accepts filesystem paths, so + the PEM is written to a private temp file (``mkstemp`` creates it 0600), + loaded, and unlinked in the same call -- it is never persisted. + """ + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.set_alpn_protocols(ALPN_PROTOCOLS) + fd, path = tempfile.mkstemp(suffix=".pem") + try: + os.write(fd, cert_pem + b"\n" + key_pem) + os.close(fd) + context.load_cert_chain(certfile=path) + finally: + os.unlink(path) + return context + + +class InMemoryTLSConfig(Config): + """Hypercorn ``Config`` that serves TLS from an in-memory ``SSLContext``. + + Hypercorn's stock ``Config`` gates TLS on ``certfile``/``keyfile`` paths and + rebuilds the context by loading those files. We hold the context directly + and override the two hooks hypercorn consults: ``ssl_enabled`` (so + ``create_sockets`` routes the listen socket into the secure bucket) and + ``create_ssl_context`` (so ``worker_serve`` uses our context). This keeps + the cert and key off disk beyond the transient load in + ``build_server_ssl_context``. + """ + + def __init__(self, ssl_context: ssl.SSLContext) -> None: + super().__init__() + self._ssl_context = ssl_context + + @property + def ssl_enabled(self) -> bool: + return True + + def create_ssl_context(self) -> ssl.SSLContext: + return self._ssl_context diff --git a/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/tls_test.py b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/tls_test.py new file mode 100644 index 00000000..6d525e2d --- /dev/null +++ b/vendor/mngr/libs/mngr_forward/imbue/mngr_forward/tls_test.py @@ -0,0 +1,85 @@ +"""Unit tests for the ephemeral in-memory TLS helpers.""" + +import ipaddress +import ssl + +from cryptography import x509 + +from imbue.mngr_forward.tls import InMemoryTLSConfig +from imbue.mngr_forward.tls import build_server_ssl_context +from imbue.mngr_forward.tls import generate_self_signed_cert + + +def test_generate_self_signed_cert_has_expected_sans() -> None: + """The cert must cover `localhost`, `*.localhost`, and `127.0.0.1`. + + `*.localhost` is required for the `agent-.localhost` workspace + subdomains (the wildcard does not match the bare `localhost` label, so both + entries are needed); `127.0.0.1` covers loopback probes that dial the IP. + """ + cert_pem, key_pem = generate_self_signed_cert() + assert b"BEGIN CERTIFICATE" in cert_pem + assert b"PRIVATE KEY" in key_pem + certificate = x509.load_pem_x509_certificate(cert_pem) + san = certificate.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + dns_names = san.get_values_for_type(x509.DNSName) + ip_addresses = san.get_values_for_type(x509.IPAddress) + assert set(dns_names) == {"localhost", "*.localhost"} + assert ipaddress.ip_address("127.0.0.1") in ip_addresses + + +def _negotiate_alpn(server_context: ssl.SSLContext, client_offers: list[str]) -> str | None: + """Drive a full TLS handshake in-memory (no sockets) and return the server's ALPN choice.""" + client_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + client_context.check_hostname = False + client_context.verify_mode = ssl.CERT_NONE + client_context.set_alpn_protocols(client_offers) + + client_in, client_out = ssl.MemoryBIO(), ssl.MemoryBIO() + server_in, server_out = ssl.MemoryBIO(), ssl.MemoryBIO() + client = client_context.wrap_bio(client_in, client_out, server_hostname="localhost") + server = server_context.wrap_bio(server_in, server_out, server_side=True) + + # Pump both directions until both sides finish the handshake. A bounded loop + # guards against a stuck handshake instead of spinning forever. + for _ in range(20): + for endpoint, out_bio, peer_in in ((client, client_out, server_in), (server, server_out, client_in)): + try: + endpoint.do_handshake() + except ssl.SSLWantReadError: + pass + pending = out_bio.read() + if pending: + peer_in.write(pending) + if client.selected_alpn_protocol() is not None and server.selected_alpn_protocol() is not None: + break + return server.selected_alpn_protocol() + + +def test_build_server_ssl_context_negotiates_h2_when_offered() -> None: + """A client offering h2 must be given h2 (the whole point of the cert path).""" + cert_pem, key_pem = generate_self_signed_cert() + context = build_server_ssl_context(cert_pem, key_pem) + assert context.minimum_version == ssl.TLSVersion.TLSv1_2 + assert _negotiate_alpn(context, ["h2", "http/1.1"]) == "h2" + + +def test_build_server_ssl_context_falls_back_to_http1_for_ws_clients() -> None: + """A client that only offers http/1.1 (e.g. a WebSocket upgrade) gets http/1.1.""" + cert_pem, key_pem = generate_self_signed_cert() + context = build_server_ssl_context(cert_pem, key_pem) + assert _negotiate_alpn(context, ["http/1.1"]) == "http/1.1" + + +def test_in_memory_tls_config_enables_ssl_and_returns_context() -> None: + """The Config subclass must report TLS enabled and hand back our context. + + Hypercorn gates the secure socket bucket on `ssl_enabled` and builds the + listener's TLS from `create_ssl_context()`, so both hooks must reflect the + in-memory context rather than the stock certfile/keyfile path behaviour. + """ + cert_pem, key_pem = generate_self_signed_cert() + context = build_server_ssl_context(cert_pem, key_pem) + config = InMemoryTLSConfig(context) + assert config.ssl_enabled is True + assert config.create_ssl_context() is context diff --git a/vendor/mngr/libs/mngr_forward/pyproject.toml b/vendor/mngr/libs/mngr_forward/pyproject.toml index 97d3bdf2..a4afbc07 100644 --- a/vendor/mngr/libs/mngr_forward/pyproject.toml +++ b/vendor/mngr/libs/mngr_forward/pyproject.toml @@ -11,11 +11,12 @@ dependencies = [ "click", "click-option-group", "fastapi>=0.115", - "uvicorn>=0.30", + "hypercorn>=0.17", "websockets>=13", "httpx>=0.27.0", "itsdangerous>=2.1", "jinja2>=3.1", + "cryptography>=42.0", "paramiko>=3.0", "loguru", "pluggy", diff --git a/vendor/mngr/libs/mngr_imbue_cloud/CHANGELOG.md b/vendor/mngr/libs/mngr_imbue_cloud/CHANGELOG.md index 390086cf..df0b9726 100644 --- a/vendor/mngr/libs/mngr_imbue_cloud/CHANGELOG.md +++ b/vendor/mngr/libs/mngr_imbue_cloud/CHANGELOG.md @@ -24,6 +24,7 @@ For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDG ### Changed +- Changed: forever-claude-template renamed to default-workspace-template. The per-box template image cache tag prefix becomes `default-workspace-template:` (from `fct:`) and the per-box cache dir becomes `.cache/mngr-slice-default-workspace-template` (from `.cache/mngr-slice-fct`); boxes prepped under the old dir keep working, as their first `--from-tag` bake rebuilds the image once and re-seeds the cache under the new name. - Changed: Imbue Cloud provider updated for mngr's new per-provider discovery — implements the bounded `discover_hosts_and_agents_within_timeouts` entry point. Because discovery reads all leased hosts and their agents in one batched pass, individual host reads are not separately bounded (still bounded by the provider-level discovery error timeout; no host is marked UNKNOWN by this path). - Changed: Pre-baked pool hosts are no longer stamped with a `workspace=` label at bake time; workspace identity lives on the host name and host id, not on a label. - Changed: **SSH host keys pinned end-to-end, removing TOFU from the imbue_cloud pool flow.** Each baked slice gets unique VM-root and container sshd host keys (no longer shared across an operator's slices). The bake records each pool host's VPS/VM-root and container sshd host public keys into the connector's `pool_hosts` row, and OVH bare-metal boxes get an ed25519 host key generated and injected during OS reinstall (recorded on the `bare_metal_servers` row). Leasing returns both host keys so the client pins them with strict host-key checking instead of scanning; the slow-path container rebuild pins its own freshly-generated key. The lima slice client, admin box SSH, and connector lease/teardown all pin the recorded key rather than disabling host-key checking. `mngr imbue_cloud admin server prep` now takes `--server-id` (instead of `--server-address`) and strictly pins the box's recorded sshd host key — there is no TOFU fallback. @@ -47,6 +48,8 @@ For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDG ### Fixed +- Fixed: "Husk" bug where a transiently-unreachable leased workspace (a sleep/wifi blip or a brief box outage) lost its agent labels and disappeared from consumers that filter on them — most importantly the minds sidebar's `is_primary` guard, which dropped the workspace and made a restart 404 with "Unknown workspace". The provider now remembers, per host, the identity (name + `certified_data`, including labels) of every agent seen in the last successful discovery pass, persisted to disk under the host's existing state dir. When a later pass cannot reach the host (or lists zero agents), discovery re-attaches the full cached set — each agent marked `"stale": true` so consumers can tell cached identity from a live listing — instead of emitting a single label-less stub. An unreachable host now surfaces as UNKNOWN (auth failures still surface as UNAUTHENTICATED), with the real failure reason preserved: unreachability is non-evidence about the container, the two indistinguishable client-side. Visibility consequences: `mngr list --active` no longer hides unreachable hosts (it excludes CRASHED but not UNKNOWN), and `mngr wait --host` no longer treats an unreachable host as terminal. +- Fixed: Imbue Cloud provider now reports the container's outer-host-loopback SSH port (`get_container_loopback_ssh_port`), which is the fixed in-VM publish port (`config.container_ssh_port`) rather than the externally-routable box-forwarded port a remote client connects to. On a lima slice the publish and connect ports differ, and using the connect port broke the VPS-resident latchkey gateway's reverse tunnel into the agent's container, leaving agents without latchkey access whenever the desktop gateway was offline. - Fixed: Connector HTTP client now retries `httpx.TransportError` with bounded exponential backoff (idempotent GET/PUT/DELETE retry on any transport error; lease/create POSTs retry only on connect-phase errors to avoid double-allocation) and raises a clean domain error (`ImbueCloud*Error`) on terminal failure. The connector is a scale-to-zero Modal app, so a call that hit a cold/scaling instance previously surfaced as a raw httpx traceback — this fixes the intermittent `502 tunnels list failed` an agent hit when toggling workspace sharing through the minds API. - Fixed: Per-box FCT image seed now invokes `uv run python -m playwright install` instead of the `playwright` console script. The FCT image builds its uv venv at `/mngr/code` and relocates the workspace to `/docker_build_code`; uv venv shebangs hardcode the original path, so the `playwright` script failed to spawn from the relocated workspace and every production (`--from-tag`) slice bake was unable to produce the box tar. Invoking via `python -m` goes through the venv's relocatable interpreter symlink and succeeds. - Fixed: Restarting a stopped imbue_cloud (leased pool) mind no longer leaves it in a broken, unrecoverable state. `ImbueCloudProvider.get_host` now probes the inner container's running state over the outer root SSH (mirroring `VpsDockerProvider.get_host`) and returns an offline host when the container is stopped, so `mngr start` routes through `start_host`. `start_host` then relaunches the container's sshd over the outer root SSH (the in-container sshd is started via `docker exec` and does not survive a `docker stop`/`start`, but the container filesystem -- including the per-host authorized key and the served host key -- is preserved across the restart) and waits for it to accept connections. diff --git a/vendor/mngr/libs/mngr_imbue_cloud/UNABRIDGED_CHANGELOG.md b/vendor/mngr/libs/mngr_imbue_cloud/UNABRIDGED_CHANGELOG.md index 742d1486..e668ff3b 100644 --- a/vendor/mngr/libs/mngr_imbue_cloud/UNABRIDGED_CHANGELOG.md +++ b/vendor/mngr/libs/mngr_imbue_cloud/UNABRIDGED_CHANGELOG.md @@ -4,6 +4,26 @@ Full, unedited changelog entries consolidated nightly from individual files in ` For a concise summary, see [CHANGELOG.md](CHANGELOG.md). +## 2026-07-13 + +Fixed the imbue_cloud "husk" bug, where a transiently-unreachable leased workspace (a sleep/wifi blip or a brief box outage) would lose its agent labels and disappear from consumers that filter on them -- most importantly the minds sidebar's `is_primary` guard, which dropped the workspace and made a restart 404 with "Unknown workspace". + +The provider now remembers, per host, the identity (name + certified_data, including labels) of every agent seen in the last successful discovery pass, persisting it to disk under the host's existing state dir so it survives an app or forward relaunch. When a later pass cannot reach the host (or a successful pass lists zero agents), discovery re-attaches that full cached set -- each agent marked `"stale": true` so consumers can tell cached identity from a live listing -- instead of emitting a single label-less stub. System-services agents and any other non-primary agents survive the unreachable window too. + +An unreachable host now surfaces as UNKNOWN instead of CRASHED (auth failures still surface as UNAUTHENTICATED), with the real failure reason preserved. Unreachability is non-evidence about the container: the box may be down, or the network path from this client may be broken, and the two are indistinguishable from the client side. CRASHED dated from a case where a TCP-level refusal genuinely implied a dead container and predated the introduction of HostState.UNKNOWN, whose documented semantics ("could not be accessed during discovery, so actual state is unknown") describe this fallback exactly. Note the visibility consequences: `mngr list --active` no longer hides unreachable hosts (it excludes CRASHED but not UNKNOWN), and `mngr wait --host` no longer treats an unreachable host as terminal. + +Only the agents' identity is restored, not their reachability. A host discovered for the first time with no cache yet behaves exactly as before (a bare lease stub, also UNKNOWN), and destroying the workspace removes the cached identity along with the rest of its state dir. + +The imbue_cloud provider now reports the container's outer-host-loopback SSH port (`get_container_loopback_ssh_port`), which is the fixed in-VM publish port (`config.container_ssh_port`) rather than the externally-routable, box-forwarded port a remote client connects to. + +This lets the VPS-resident latchkey gateway reverse-tunnel into the agent's container on the correct port. On a lima slice the publish and connect ports differ, and using the connect port broke the tunnel, leaving agents without latchkey access whenever the desktop gateway was offline. + +## 2026-07-11 + +The forever-claude-template repo is being renamed to default-workspace-template (with the `fct`/`FCT` shorthand expanded to `default_workspace_template`/`DEFAULT_WORKSPACE_TEMPLATE` forms). + +The per-box template image cache tag prefix changes from `fct:` to `default-workspace-template:`, and the per-box cache dir from `.cache/mngr-slice-fct` to `.cache/mngr-slice-default-workspace-template`. The cache build lock now `mkdir -p`s the cache dir on demand, so boxes prepped under the old dir name keep working: their first production `--from-tag` bake rebuilds the image once (instead of loading the old cached tar) and re-seeds the cache under the new name. + ## 2026-07-09 `mngr imbue_cloud admin server order` now takes a `--dry-run` flag: it builds and prices a non-committal OVH cart, prints the real price preview plus the derived server specs and slice count, then deletes the cart without ordering. No charge, no interactive prompt, and no DB write, so it can be used to confirm price/specs before committing to an order. `--dry-run` takes precedence over `--yes`, so `--dry-run --yes` never charges. diff --git a/vendor/mngr/libs/mngr_imbue_cloud/changelog/mngr-fct-rename.md b/vendor/mngr/libs/mngr_imbue_cloud/changelog/mngr-fct-rename.md deleted file mode 100644 index b80c23f9..00000000 --- a/vendor/mngr/libs/mngr_imbue_cloud/changelog/mngr-fct-rename.md +++ /dev/null @@ -1,3 +0,0 @@ -The forever-claude-template repo is being renamed to default-workspace-template (with the `fct`/`FCT` shorthand expanded to `default_workspace_template`/`DEFAULT_WORKSPACE_TEMPLATE` forms). - -The per-box template image cache tag prefix changes from `fct:` to `default-workspace-template:`, and the per-box cache dir from `.cache/mngr-slice-fct` to `.cache/mngr-slice-default-workspace-template`. The cache build lock now `mkdir -p`s the cache dir on demand, so boxes prepped under the old dir name keep working: their first production `--from-tag` bake rebuilds the image once (instead of loading the old cached tar) and re-seeds the cache under the new name. diff --git a/vendor/mngr/libs/mngr_imbue_cloud/imbue/mngr_imbue_cloud/providers/instance.py b/vendor/mngr/libs/mngr_imbue_cloud/imbue/mngr_imbue_cloud/providers/instance.py index 30e37dee..93d1b552 100644 --- a/vendor/mngr/libs/mngr_imbue_cloud/imbue/mngr_imbue_cloud/providers/instance.py +++ b/vendor/mngr/libs/mngr_imbue_cloud/imbue/mngr_imbue_cloud/providers/instance.py @@ -106,6 +106,8 @@ from imbue.mngr.providers.ssh_utils import load_or_create_ssh_keypair from imbue.mngr.providers.ssh_utils import save_ssh_keypair from imbue.mngr.providers.ssh_utils import wait_for_sshd +from imbue.mngr.utils.file_utils import atomic_write +from imbue.mngr.utils.file_utils import read_json_dict from imbue.mngr_imbue_cloud.config import ImbueCloudProviderConfig from imbue.mngr_imbue_cloud.config import get_provider_data_dir from imbue.mngr_imbue_cloud.connector.auth_helper import get_active_token @@ -297,6 +299,73 @@ def _host_keypair_paths(self, host_id: HostId) -> tuple[Path, Path]: def _host_known_hosts_path(self, host_id: HostId) -> Path: return self._host_state_dir(host_id) / "known_hosts" + def _last_known_agents_path(self, host_id: HostId) -> Path: + return self._host_state_dir(host_id) / "last_known_agents.json" + + # ------------------------------------------------------------------ + # Sticky agent identity + # + # Discovery persists the identity (name + certified_data) of the agents + # seen in the last successful outer-listing pass, and re-attaches it in the + # fallback paths (outer SSH unreachable, or a successful pass with zero + # agents). Without this, a transiently-unreachable host emits a single + # label-less "husk" agent named by its lease id, and every consumer that + # filters on labels -- most importantly the minds forward's + # ``has(agent.labels.is_primary)`` -- silently drops the workspace, so it + # vanishes from the sidebar and 404s on restart. Persisting to disk (not + # just in-memory) lets the identity survive an app/forward relaunch into a + # flaky-network window, which is the production failure mode this fixes. + # ------------------------------------------------------------------ + + def _persist_last_known_agents(self, host_id: HostId, agent_refs: Sequence[DiscoveredAgent]) -> None: + """Persist the identity of the agents seen in a successful listing pass. + + Stored as ``agent_id -> {name, certified_data}`` under the per-host + state dir. Best-effort: a write failure is logged, not raised -- + discovery must not fail just because the sticky-identity cache could not + be refreshed. Only called with a non-empty ``agent_refs`` (a pass that + found real agents), so the cache is never clobbered by an empty result. + """ + payload = { + str(ref.agent_id): {"name": str(ref.agent_name), "certified_data": dict(ref.certified_data)} + for ref in agent_refs + } + try: + atomic_write(self._last_known_agents_path(host_id), json.dumps(payload, indent=2)) + except OSError as exc: + logger.warning( + "imbue_cloud[{}] could not persist last-known agents for host {}: {}", self.name, host_id, exc + ) + + def _load_last_known_agents(self, host_id: HostId) -> list[DiscoveredAgent]: + """Re-attach the last-known agents for a host, each marked stale. + + Each reattached agent carries the persisted certified_data plus a + synthetic ``"stale": true`` key so consumers can distinguish cached + identity from a live listing. Returns an empty list when no cache exists + yet (first-ever discovery of a host), so the caller keeps its bare + lease-stub behavior with no change from today. + """ + payload = read_json_dict(self._last_known_agents_path(host_id)) + agents: list[DiscoveredAgent] = [] + for agent_id_str, entry in payload.items(): + if not isinstance(entry, dict): + continue + name = entry.get("name") + certified = entry.get("certified_data", {}) + if not name or not isinstance(certified, dict): + continue + agents.append( + DiscoveredAgent( + agent_id=AgentId(agent_id_str), + agent_name=AgentName(name), + host_id=host_id, + provider_name=self.name, + certified_data={**certified, "stale": True}, + ) + ) + return agents + # ------------------------------------------------------------------ # Auth helper # ------------------------------------------------------------------ @@ -439,7 +508,7 @@ def discover_hosts( # (friendly name, image, tags, agents). The cached raw output is then # consumed by ``get_host_and_agent_details`` without another SSH. # - # Lease-only synthesis (with state=CRASHED and failure_reason carrying + # Lease-only synthesis (with state=UNKNOWN and failure_reason carrying # the underlying error) is reserved for the last-resort case where # even the outer SSH is unreachable -- in normal operation we expect # outer SSH to be reachable for every leased VPS. @@ -487,18 +556,31 @@ def discover_hosts_and_agents( raw, outer_error, is_auth_failure = self._collect_listing_raw_via_outer(entry) if raw is None: # Outer SSH itself failed; fall back to a lease-only stub - # so the host doesn't disappear from `mngr list`. The state - # depends on whether the failure was an auth mismatch (the - # host is reachable, our key is just wrong) or something - # more terminal (network down, host destroyed). - fallback_state = HostState.UNAUTHENTICATED if is_auth_failure else HostState.CRASHED + # so the host doesn't disappear from `mngr list`. An auth + # mismatch means the host answered (it's reachable, our key + # is just wrong) -> UNAUTHENTICATED. Any other failure means + # we could not observe the host at all -- the box may be + # down, or the network path from this client may be broken -- + # so the state is UNKNOWN, not CRASHED: an unreachable host + # is non-evidence about the container, and consumers (e.g. + # the minds recovery page) must not read it as a positive + # "container is down" verdict. + fallback_state = HostState.UNAUTHENTICATED if is_auth_failure else HostState.UNKNOWN host_ref = DiscoveredHost( host_id=host_id, host_name=HostName(entry.host_name), provider_name=self.name, host_state=fallback_state, ) - agent_refs = [ + # Re-attach the full set of agents last seen on this host (each + # with its cached certified_data, marked stale) so the workspace + # keeps its labels -- and its is_primary sidebar/restart guard -- + # through the unreachable window. Only when nothing was ever + # cached (first-ever discovery) do we fall back to the bare + # lease stub, preserving today's behavior for that case. The + # host state stays truthfully UNKNOWN/UNAUTHENTICATED: cached + # data restores identity, not liveness. + agent_refs = self._load_last_known_agents(host_id) or [ DiscoveredAgent( agent_id=AgentId(entry.agent_id), agent_name=AgentName(entry.agent_id), @@ -549,18 +631,24 @@ def discover_hosts_and_agents( certified_data=data, ) ) - # If the outer-SSH discovery returned no agents (e.g. container - # gone, or data.json is empty), still synthesize a single agent - # from the lease so the host shows in the listing. - if not agent_refs: - agent_refs.append( + if agent_refs: + # A real listing: refresh the sticky-identity cache so a later + # unreachable pass can re-attach exactly these agents. + self._persist_last_known_agents(host_id, agent_refs) + else: + # The outer-SSH discovery returned no agents (e.g. container + # gone, or data.json is empty). Re-attach the last-known agents + # (marked stale) if we have them, so the host keeps its labels; + # otherwise synthesize a single agent from the lease so the host + # still shows in the listing (unchanged first-discovery behavior). + agent_refs = self._load_last_known_agents(host_id) or [ DiscoveredAgent( agent_id=AgentId(entry.agent_id), agent_name=AgentName(entry.agent_id), host_id=host_id, provider_name=self.name, ) - ) + ] result[host_ref] = agent_refs return result @@ -576,7 +664,7 @@ def _collect_listing_raw_via_outer( reached. ``is_auth_failure`` is True iff the failure was an authentication error (``HostAuthenticationError``) -- in that case the host is reachable but our key was rejected, which is the - ``UNAUTHENTICATED`` state, not ``CRASHED``. + ``UNAUTHENTICATED`` state, not ``UNKNOWN``. """ host_id = HostId(lease.host_id) host_dir = str(self.host_dir) @@ -637,7 +725,7 @@ def get_host_and_agent_details( round-trip and cached the parsed data; this method just shapes it into the typed details structures. When the cached raw indicates outer SSH failed during discovery, fall back to lease-only details - (state=CRASHED, ssh from lease, failure_reason carrying the + (state=UNKNOWN, ssh from lease, failure_reason carrying the original error). """ host_id = host_ref.host_id @@ -742,8 +830,8 @@ def _build_offline_details_from_lease( populated so the user can see the unreachable address; ``failure_reason`` carries the underlying error. The state comes from ``host_ref.host_state`` (which discovery set to - ``UNAUTHENTICATED`` for auth failures and ``CRASHED`` for other - outer-SSH errors), with ``CRASHED`` as a safe default if it's + ``UNAUTHENTICATED`` for auth failures and ``UNKNOWN`` for other + outer-SSH errors), with ``UNKNOWN`` as a safe default if it's unset. """ ssh_info = self._build_lease_ssh_info(host_ref.host_id, lease) @@ -751,7 +839,7 @@ def _build_offline_details_from_lease( id=host_ref.host_id, name=str(host_ref.host_name), provider_name=host_ref.provider_name, - state=host_ref.host_state or HostState.CRASHED, + state=host_ref.host_state or HostState.UNKNOWN, ssh=ssh_info, failure_reason=failure_message, ) @@ -1910,6 +1998,15 @@ def outer_host_id_for(self, host_id: HostId) -> str | None: raise HostNotFoundError(self.name, host_id) return f"outer:{self.name}:{leased.vps_address}" + def get_container_loopback_ssh_port(self, host_id: HostId) -> int | None: + # The container is always published inside the VPS/VM on the fixed + # ``config.container_ssh_port``; an external client reaches it at the + # lease's ``container_ssh_port`` (equal for an OVH VPS, but a distinct + # box-forwarded port for a slice). A service running *on the outer host* + # (the VPS-resident latchkey gateway) must reverse-tunnel into the + # container on the fixed publish port, not the external one. + return self.config.container_ssh_port + @contextmanager def outer_host_for(self, host_id: HostId) -> Iterator[OuterHostInterface | None]: """Open the outer host (the leased VPS itself, root@vps_address:ssh_port). diff --git a/vendor/mngr/libs/mngr_imbue_cloud/imbue/mngr_imbue_cloud/providers/instance_test.py b/vendor/mngr/libs/mngr_imbue_cloud/imbue/mngr_imbue_cloud/providers/instance_test.py index 53769e83..d80976b3 100644 --- a/vendor/mngr/libs/mngr_imbue_cloud/imbue/mngr_imbue_cloud/providers/instance_test.py +++ b/vendor/mngr/libs/mngr_imbue_cloud/imbue/mngr_imbue_cloud/providers/instance_test.py @@ -33,6 +33,7 @@ from imbue.mngr.primitives import HostState from imbue.mngr.primitives import ImageReference from imbue.mngr.primitives import ProviderInstanceName +from imbue.mngr_imbue_cloud.config import ImbueCloudProviderConfig from imbue.mngr_imbue_cloud.data_types import LeaseAttributes from imbue.mngr_imbue_cloud.data_types import LeasedHostInfo from imbue.mngr_imbue_cloud.errors import FastPathUnavailableError @@ -109,15 +110,15 @@ def test_build_offline_details_from_lease_preserves_host_and_failure_reason(tmp_ container_ssh_port=2222, agent_id=str(agent_id), host_id=str(host_id), - host_name="crashed-host", + host_name="unreachable-host", attributes={}, leased_at="2025-01-01T00:00:00Z", ) host_ref = DiscoveredHost( host_id=host_id, - host_name=HostName("crashed-host"), + host_name=HostName("unreachable-host"), provider_name=provider_name, - host_state=HostState.CRASHED, + host_state=HostState.UNKNOWN, ) agent_ref = DiscoveredAgent( host_id=host_id, @@ -147,9 +148,9 @@ def test_build_offline_details_from_lease_preserves_host_and_failure_reason(tmp_ assert host_details.ssh.user == lease.ssh_user assert host_details.ssh.host == lease.vps_address assert host_details.ssh.port == lease.container_ssh_port - # State defaults to CRASHED in the lease-only fallback (we have no - # outer-SSH-derived state to be more specific). - assert host_details.state == HostState.CRASHED + # State passes through from discovery's fallback (UNKNOWN: the host was + # not observable, so no container-state verdict can be derived from it). + assert host_details.state == HostState.UNKNOWN # ``failure_reason`` carries the underlying error. assert host_details.failure_reason == failure_message # One agent_details per agent_ref, all attached to the offline host. @@ -391,6 +392,23 @@ def _index_of(commands: list[str], substring: str) -> int: raise AssertionError(f"no recorded command contains {substring!r}; recorded={commands}") +def test_get_container_loopback_ssh_port_returns_in_vm_publish_port_not_lease_connect_port() -> None: + """The reverse-tunnel publish port must be the fixed in-VM port, not the box-forwarded connect port. + + The VPS-resident latchkey gateway reverse-tunnels into the container from the + *outer host's* loopback, where the container's sshd is published on the fixed + ``config.container_ssh_port`` -- not on the lease's ``container_ssh_port``, + which for a slice is a distinct box-forwarded port a remote client uses. + """ + config = ImbueCloudProviderConfig.model_construct(container_ssh_port=2222) + provider = ImbueCloudProvider.model_construct(name=ProviderInstanceName("imbue-cloud-test"), config=config) + # A slice's external/connect port differs from the in-VM publish port; the + # provider must surface the publish port regardless. + slice_connect_port = 22005 + assert slice_connect_port != config.container_ssh_port + assert provider.get_container_loopback_ssh_port(HostId.generate()) == config.container_ssh_port + + def test_get_host_returns_offline_host_when_container_stopped(tmp_path: Path, temp_mngr_ctx: MngrContext) -> None: """A stopped leased container must resolve to an OFFLINE host. @@ -766,3 +784,216 @@ def test_fast_path_rejects_image_swap_and_names_only_the_image(temp_mngr_ctx: Mn assert "ghcr.io/example/custom:latest" in message assert "start args" not in message assert not provider._did_reach_fast_path + + +# ============================================================================= +# Sticky agent labels (husk fix): discovery persists the identity (name + +# certified_data) of the agents seen in the last successful outer-listing pass, +# and re-attaches that full set -- each marked ``"stale": true`` -- in the two +# fallback paths (outer SSH unreachable, or a successful pass with zero agents). +# This keeps a transiently-unreachable workspace's labels (most importantly +# ``is_primary``), so it never collapses to a single label-less "husk" agent and +# vanishes from consumers that filter on labels. Persisting to disk lets the +# identity survive an app/forward relaunch into a flaky-network window. +# ============================================================================= + + +_STICKY_PROVIDER_NAME = ProviderInstanceName("imbue-cloud-test") + + +class _SequencedListingProvider(ImbueCloudProvider): + """Drives ``discover_hosts_and_agents`` against a queue of canned outer-listing responses. + + Each ``discover_hosts_and_agents`` pass pops one ``(raw, error, is_auth)`` + tuple, so a test can script a successful pass followed by an unreachable one + and assert on how identity is persisted and re-attached. Everything below the + outer-SSH boundary (the persist/load-from-disk logic, the ref-building loop) + runs for real against ``mngr_ctx.profile_dir``. + """ + + _lease: LeasedHostInfo | None = None + _responses: list[tuple[dict[str, Any] | None, str | None, bool]] = [] + + def _list_leased_hosts_cached(self) -> list[LeasedHostInfo]: + return [self._lease] if self._lease is not None else [] + + def _collect_listing_raw_via_outer(self, lease: LeasedHostInfo) -> tuple[dict[str, Any] | None, str | None, bool]: + return self._responses.pop(0) + + +def _agent_data(name: str, labels: Mapping[str, str], agent_type: str) -> dict[str, Any]: + return {"id": str(AgentId.generate()), "name": name, "labels": dict(labels), "type": agent_type} + + +def _raw_with_agents(agent_datas: list[dict[str, Any]]) -> dict[str, Any]: + return { + "container_state": RUNNING_CONTAINER_STATE, + "certified_data": {"image": "some-image"}, + "agents": [{"data": data} for data in agent_datas], + } + + +def _make_sequenced_provider( + lease: LeasedHostInfo, + responses: list[tuple[dict[str, Any] | None, str | None, bool]], + mngr_ctx: MngrContext, +) -> _SequencedListingProvider: + return _SequencedListingProvider.model_construct( + name=_STICKY_PROVIDER_NAME, + mngr_ctx=mngr_ctx, + _lease=lease, + _responses=list(responses), + ) + + +def _only_entry( + result: dict[DiscoveredHost, list[DiscoveredAgent]], +) -> tuple[DiscoveredHost, list[DiscoveredAgent]]: + assert len(result) == 1 + host_ref, agents = next(iter(result.items())) + return host_ref, agents + + +def test_unreachable_pass_reattaches_all_cached_agents_marked_stale(temp_mngr_ctx: MngrContext) -> None: + """A successful pass persists every agent; a following unreachable pass re-attaches + the FULL cached set (including system-services), each with its labels and a stale marker.""" + host_id = HostId.generate() + lease = _make_lease(host_id) + primary = _agent_data("primary-agent", {"is_primary": "true"}, "codex") + services = _agent_data("system-services", {"is_system": "true"}, "system-services") + provider = _make_sequenced_provider( + lease, + [ + (_raw_with_agents([primary, services]), None, False), + (None, "outer SSH unreachable: connection timed out", False), + ], + temp_mngr_ctx, + ) + + _, live_agents = _only_entry(provider.discover_hosts_and_agents(cg=temp_mngr_ctx.concurrency_group)) + # Live refs carry the real data and are NOT marked stale. + assert {str(agent.agent_name) for agent in live_agents} == {"primary-agent", "system-services"} + assert all("stale" not in agent.certified_data for agent in live_agents) + + host_ref, cached_agents = _only_entry(provider.discover_hosts_and_agents(cg=temp_mngr_ctx.concurrency_group)) + # The host is truthfully UNKNOWN -- unreachable is non-evidence about the + # container (cached data restores identity, not liveness). + assert host_ref.host_state == HostState.UNKNOWN + # The full agent set survives -- not a single bare lease stub. + assert {str(agent.agent_name) for agent in cached_agents} == {"primary-agent", "system-services"} + # Every re-attached agent is marked stale and keeps its labels. + assert all(agent.certified_data.get("stale") is True for agent in cached_agents) + primary_ref = next(agent for agent in cached_agents if str(agent.agent_name) == "primary-agent") + assert primary_ref.labels["is_primary"] == "true" + + +def test_cached_identity_survives_a_fresh_provider_instance(temp_mngr_ctx: MngrContext) -> None: + """The identity is persisted to disk, so a brand-new provider instance (the app/forward + relaunch case) re-attaches it on an unreachable pass -- the production failure mode.""" + host_id = HostId.generate() + lease = _make_lease(host_id) + primary = _agent_data("primary-agent", {"is_primary": "true"}, "codex") + + first_provider = _make_sequenced_provider(lease, [(_raw_with_agents([primary]), None, False)], temp_mngr_ctx) + first_provider.discover_hosts_and_agents(cg=temp_mngr_ctx.concurrency_group) + + # A fresh instance shares only the profile_dir + provider name, so it must read + # the persisted cache from disk (no in-memory carry-over). + second_provider = _make_sequenced_provider(lease, [(None, "outer SSH unreachable", False)], temp_mngr_ctx) + host_ref, agents = _only_entry(second_provider.discover_hosts_and_agents(cg=temp_mngr_ctx.concurrency_group)) + + assert [str(agent.agent_name) for agent in agents] == ["primary-agent"] + assert agents[0].labels["is_primary"] == "true" + assert agents[0].certified_data.get("stale") is True + + +def test_unauthenticated_pass_reattaches_cached_agents(temp_mngr_ctx: MngrContext) -> None: + """An auth rejection (UNAUTHENTICATED) re-attaches cached identity just like UNKNOWN does.""" + host_id = HostId.generate() + lease = _make_lease(host_id) + primary = _agent_data("primary-agent", {"is_primary": "true"}, "codex") + provider = _make_sequenced_provider( + lease, + [ + (_raw_with_agents([primary]), None, False), + (None, "outer SSH authentication failed", True), + ], + temp_mngr_ctx, + ) + + provider.discover_hosts_and_agents(cg=temp_mngr_ctx.concurrency_group) + host_ref, agents = _only_entry(provider.discover_hosts_and_agents(cg=temp_mngr_ctx.concurrency_group)) + + assert host_ref.host_state == HostState.UNAUTHENTICATED + assert [str(agent.agent_name) for agent in agents] == ["primary-agent"] + assert agents[0].certified_data.get("stale") is True + + +def test_empty_agents_successful_pass_reattaches_cached_agents(temp_mngr_ctx: MngrContext) -> None: + """A successful pass that lists zero agents (stopped container / empty data.json) re-attaches + the cached identity rather than synthesizing a bare lease stub.""" + host_id = HostId.generate() + lease = _make_lease(host_id) + primary = _agent_data("primary-agent", {"is_primary": "true"}, "codex") + provider = _make_sequenced_provider( + lease, + [ + (_raw_with_agents([primary]), None, False), + (_raw_with_agents([]), None, False), + ], + temp_mngr_ctx, + ) + + provider.discover_hosts_and_agents(cg=temp_mngr_ctx.concurrency_group) + _, agents = _only_entry(provider.discover_hosts_and_agents(cg=temp_mngr_ctx.concurrency_group)) + + assert [str(agent.agent_name) for agent in agents] == ["primary-agent"] + assert agents[0].certified_data.get("stale") is True + + +def test_first_discovery_with_no_cache_falls_back_to_bare_lease_stub(temp_mngr_ctx: MngrContext) -> None: + """With no successful pass ever observed, an unreachable pass behaves exactly as today: + a single lease stub named by the lease's agent_id, with no cached (stale) identity.""" + host_id = HostId.generate() + lease = _make_lease(host_id) + provider = _make_sequenced_provider(lease, [(None, "outer SSH unreachable", False)], temp_mngr_ctx) + + host_ref, agents = _only_entry(provider.discover_hosts_and_agents(cg=temp_mngr_ctx.concurrency_group)) + + assert host_ref.host_state == HostState.UNKNOWN + assert len(agents) == 1 + assert str(agents[0].agent_id) == lease.agent_id + assert "stale" not in agents[0].certified_data + + +def test_reattached_identity_flows_through_to_agent_details(temp_mngr_ctx: MngrContext) -> None: + """The full round trip: a successful pass persists identity, an unreachable pass re-attaches it, + and ``get_host_and_agent_details`` shapes the re-attached refs into AgentDetails that still carry + the workspace name and ``is_primary`` label -- the end-to-end path that keeps a transiently + unreachable workspace in the sidebar instead of collapsing it to a husk.""" + host_id = HostId.generate() + lease = _make_lease(host_id) + primary = _agent_data("primary-agent", {"is_primary": "true"}, "codex") + provider = _make_sequenced_provider( + lease, + [ + (_raw_with_agents([primary]), None, False), + (None, "outer SSH unreachable: connection timed out", False), + ], + temp_mngr_ctx, + ) + + # First pass persists the agent's identity; the second (unreachable) pass re-attaches it. + provider.discover_hosts_and_agents(cg=temp_mngr_ctx.concurrency_group) + host_ref, agent_refs = _only_entry(provider.discover_hosts_and_agents(cg=temp_mngr_ctx.concurrency_group)) + assert host_ref.host_state == HostState.UNKNOWN + + # The rich-details path shapes the re-attached refs into AgentDetails without any change of its + # own -- the cached name and labels flow straight through, and the host stays truthfully UNKNOWN. + host_details, agent_details_list = provider.get_host_and_agent_details(host_ref, agent_refs) + assert host_details.state == HostState.UNKNOWN + assert host_details.failure_reason is not None + assert len(agent_details_list) == 1 + agent_details = agent_details_list[0] + assert str(agent_details.name) == "primary-agent" + assert agent_details.labels["is_primary"] == "true" diff --git a/vendor/mngr/libs/mngr_latchkey/CHANGELOG.md b/vendor/mngr/libs/mngr_latchkey/CHANGELOG.md index 3e5f7261..ea63ceba 100644 --- a/vendor/mngr/libs/mngr_latchkey/CHANGELOG.md +++ b/vendor/mngr/libs/mngr_latchkey/CHANGELOG.md @@ -23,6 +23,7 @@ For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDG ### Changed +- Changed: VPS-resident Latchkey gateway and its VPS→container reverse SSH tunnel are now supervised by `supervisord` instead of being spawned detached (`nohup` + PID-file guard). Both processes are registered as `autostart`/`autorestart` programs, so a crashed gateway or tunnel is restarted automatically without a desktop round-trip. A VPS provisioned by an older build is migrated automatically (legacy `nohup`-tracked processes killed and any persisted encryption-key/password files removed before the supervisord programs start). The reverse SSH tunnel now carries keepalive/timeout flags (`ServerAliveInterval`, `ServerAliveCountMax`, `TCPKeepAlive`, `ConnectTimeout`, `BatchMode`) so a wedged connection is torn down and `supervisord` re-establishes a fresh one. The gateway's secrets (encryption key + derived listen password) live in a RAM-backed tmpfs directory under `/run` rather than on the persistent disk; provisioning verifies the directory is genuinely tmpfs/ramfs before writing and refuses otherwise. A full reboot wipes the secrets (crash recovery supported; reboot recovery intentionally not). - Changed: The latchkey discovery stream consumer now logs provider-level discovery errors once per process via the shared `DiscoveryErrorLogSuppressor` (with an info-level recovery line when discovery next succeeds), instead of one warning per poll cycle. Host- and agent-attributed discovery errors keep logging on every occurrence. - Changed: Cross-workspace (`minds-workspaces`) permission grants now attach as permissions on the single `latchkey-self` scope (like file-sharing and accounts) instead of their own dedicated `minds-workspaces` detent scope. Because both scopes matched the gateway-self domain alone, detent's first-matching-scope-wins evaluation made the rule *order* in `latchkey_permissions.json` load-bearing (a domain-only catch-all placed first would shadow the narrower grant). With one gateway-self rule, order no longer affects whether a grant takes effect; the order-normalizing machinery (in both the gateway's approve merge and `register_agent_for_host`) is gone. The `MINDS_WORKSPACES_SCOPE` constant is removed, and the now-unused `scope` and `gateway_self_host` fields drop out of the shared `workspace_permissions.json` catalog. Historical two-scope layouts are folded to the new shape by the first data-format migration (see Added). - Changed: `mngr latchkey forward` discovery consumer migrated to mngr's per-provider discovery model. Each `DISCOVERY_PROVIDER` snapshot and every incremental discovery event now folds into the shared `DiscoveryStateAggregator` instead of hand-rolling its own prior-vs-fresh reconciliation; a snapshot is authoritative only for its own provider (one provider's poll no longer affects another's agents). Legacy global `DISCOVERY_FULL` snapshot handling has been removed. Fixes a span-awareness gap where a per-provider snapshot could re-establish a reverse tunnel for an agent destroyed during that snapshot's discovery span. @@ -32,6 +33,8 @@ For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDG ### Fixed +- Fixed: VPS-resident ("secondary") latchkey gateway was unreachable from the agent's container on lima-slice hosts, leaving agents with no latchkey access whenever the desktop (primary) gateway was unavailable. The VPS→container reverse tunnel was opened to the container's externally-routable SSH port instead of the port the container's sshd is published on from the outer host's own loopback; on a slice (where those two ports differ) the tunnel was refused, so the secondary gateway never bound inside the container. The reverse tunnel now uses the outer-host-loopback port supplied by the provider. Tunnel setup is also no longer silently treated as successful when the backgrounded `ssh -R` process dies immediately (e.g. connection refused or a failed forward bind): the launch confirms the process survives a short window, so a broken tunnel surfaces as a failed provision and is retried instead of being cached as "provisioned". +- Fixed: `mngr latchkey forward` now supervises the shared `latchkey gateway` subprocess — a background health check detects when the gateway has died mid-session and respawns it on its original port (so agent reverse tunnels and the published gateway port stay valid across the restart). Previously a crashed gateway went unnoticed (discovery and reverse tunnels stayed up so nothing restarted it) and agents could no longer reach the gateway until the whole app was restarted. `Latchkey.is_gateway_running` and `Latchkey.start_gateway` are now liveness-aware (checked via `poll()`) rather than merely tracking whether a record exists, so a dead gateway reads as not-running and is respawned instead of returning a stale port. - Fixed: Latchkey gateway `--max-body-size` raised to 512 MiB (from the 10 MiB default) at both spawn sites (desktop and VPS-resident), so gateway-authenticated `git push` no longer fails on packfiles above 10 MiB (a minds template push is roughly 30 MiB today). Already-running gateways keep the old cap until they restart. - Fixed: A granted `minds-workspaces` permission was silently rejected with `403` despite being present in the agent's permissions file. The agent baseline's `latchkey-self` scope is domain-only and matched first, vetoing the request before later rules were consulted. Grant rules are now kept ordered with `latchkey-self` last — both when a grant is approved and when an agent is (re)registered — so a narrower same-domain scope is evaluated first and its grants take effect. Schema and file-sharing access (granted under `latchkey-self` itself) is unaffected. - Fixed: Repeated macOS system keychain access dialogs (mentioning Latchkey) during normal Minds use. The detached `latchkey ensure-browser` subprocess is now spawned with `LATCHKEY_ENCRYPTION_KEY` injected into its environment, matching other Latchkey invocations. diff --git a/vendor/mngr/libs/mngr_latchkey/README.md b/vendor/mngr/libs/mngr_latchkey/README.md index cf3225a2..ee7beeb1 100644 --- a/vendor/mngr/libs/mngr_latchkey/README.md +++ b/vendor/mngr/libs/mngr_latchkey/README.md @@ -28,6 +28,12 @@ agents lose their gateway endpoint until the next `mngr latchkey forward` is started; the per-host permissions files survive across restarts. +While running, the supervisor also health-checks the shared gateway +subprocess: if it dies mid-session it is respawned on its original +port (so agent reverse tunnels and the published gateway port stay +valid), rather than leaving agent traffic silently broken until the +supervisor itself is restarted. + ### Wiring a new agent using the CLI interface ```sh diff --git a/vendor/mngr/libs/mngr_latchkey/UNABRIDGED_CHANGELOG.md b/vendor/mngr/libs/mngr_latchkey/UNABRIDGED_CHANGELOG.md index ac6aebf1..d379232b 100644 --- a/vendor/mngr/libs/mngr_latchkey/UNABRIDGED_CHANGELOG.md +++ b/vendor/mngr/libs/mngr_latchkey/UNABRIDGED_CHANGELOG.md @@ -4,6 +4,28 @@ Full, unedited changelog entries consolidated nightly from individual files in ` For a concise summary, see [CHANGELOG.md](CHANGELOG.md). +## 2026-07-13 + +The `mngr latchkey forward` daemon now tells Sentry to ignore the paramiko/pyinfra stdlib loggers. The daemon reverse-tunnels the shared gateway into every agent via paramiko; when a target went offline and the health check retried, paramiko's transport thread logged the connection-reset failure at ERROR level, and Sentry's default logging integration captured each one as an event. Those events were not even rate-limited (the stdlib records carry no exception info or fingerprint), so a handful of users produced tens of thousands of Sentry events. The daemon now drops that already-handled noise while still reporting genuine failures raised through loguru. + +Fixed the VPS-resident ("secondary") latchkey gateway being unreachable from the agent's container on lima-slice hosts, which left agents with no latchkey access whenever the desktop (primary) gateway was unavailable -- e.g. while the user's laptop was offline. + +The VPS->container reverse tunnel was opened to the container's externally-routable SSH port instead of the port the container's sshd is published on from the outer host's own loopback. On a slice (where those two ports differ) the tunnel was refused, so the secondary gateway never bound inside the container. The reverse tunnel now uses the outer-host-loopback port supplied by the provider. + +Tunnel setup is also no longer silently treated as successful when the `ssh -R` process dies immediately (e.g. connection refused or a failed forward bind): after backgrounding it, the launch now confirms the process survives a short window, so a broken tunnel surfaces as a failed provision and is retried instead of being cached as "provisioned". + +The VPS-resident Latchkey gateway and its VPS->container reverse SSH tunnel are now supervised by `supervisord` instead of being spawned detached (`nohup` + a PID-file guard). `supervisord` is installed from the distro package during remote-gateway provisioning; both processes are registered as `autostart`/`autorestart` programs, so a crashed gateway or tunnel is restarted automatically without a desktop round-trip. + +A VPS previously provisioned by an older build (which launched the gateway and tunnel detached via `nohup`, tracked by `~/.latchkey/{gateway,tunnel}.pid`) is migrated automatically: provisioning kills those legacy processes (freeing the gateway port and the container forward bind) and removes any encryption key/password an intermediate build had persisted to disk, before the `supervisord` programs start. Without this the new programs would fail to start against the still-running old ones. + +The reverse SSH tunnel now carries keepalive and timeout flags (`ServerAliveInterval`, `ServerAliveCountMax`, `TCPKeepAlive`, `ConnectTimeout`, `BatchMode`) so a connection wedged by a paused-then-resumed VM is detected and torn down, letting `supervisord` re-establish a fresh tunnel. + +The gateway's secrets (the encryption key and derived listen password) are kept in a RAM-backed tmpfs directory under `/run` rather than on the persistent disk: this keeps the encryption key off disk (a key file beside the encrypted credential store would be equivalent to storing the credentials in plaintext against a disk-snapshot threat) while still surviving process crashes so `supervisord` can restart the gateway. Provisioning verifies the directory is genuinely RAM-backed (tmpfs/ramfs) before writing the key and refuses to proceed otherwise, so the key can never be silently written to a disk-backed filesystem. The tradeoff is that a full reboot wipes the secrets, leaving the gateway down until the next provisioning pass re-writes them (crash recovery is supported; reboot recovery is intentionally not). + +`mngr latchkey forward` now supervises the shared `latchkey gateway` subprocess: a background health check detects when the gateway has died mid-session and respawns it on its original port (so agent reverse tunnels and the published gateway port stay valid across the restart). Previously a crashed gateway went unnoticed -- discovery and reverse tunnels stayed up, so nothing restarted it -- and agents could no longer reach the gateway until the whole app was restarted. + +`Latchkey.is_gateway_running` and `Latchkey.start_gateway` are now liveness-aware: they check the gateway subprocess's actual status (via `poll()`) rather than merely whether a record is tracked, so a dead gateway reads as not-running and is respawned instead of returning a stale port. + ## 2026-07-10 The latchkey discovery stream consumer no longer logs a "Discovery error from ..." warning on every poll cycle for a provider stuck on the same failure (e.g. missing credentials): provider-level discovery errors are now logged once per process via the shared `DiscoveryErrorLogSuppressor`, with an info-level recovery line (and re-armed suppression) when the provider's discovery next succeeds. Host- and agent-attributed discovery errors keep logging on every occurrence. diff --git a/vendor/mngr/libs/mngr_latchkey/changelog/hynek-bump-latchkey-to-2.20.2.md b/vendor/mngr/libs/mngr_latchkey/changelog/hynek-bump-latchkey-to-2.20.2.md new file mode 100644 index 00000000..3e16d5fa --- /dev/null +++ b/vendor/mngr/libs/mngr_latchkey/changelog/hynek-bump-latchkey-to-2.20.2.md @@ -0,0 +1 @@ +Update Latchkey to include support for GitHub's GraphQL API. diff --git a/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/cli.py b/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/cli.py index 516fe403..3946f992 100644 --- a/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/cli.py +++ b/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/cli.py @@ -36,6 +36,7 @@ from pydantic import ConfigDict from pydantic import Field +from imbue.concurrency_group.concurrency_group import ConcurrencyGroup from imbue.imbue_common.frozen_model import FrozenModel from imbue.imbue_common.sentry.core import flush_sentry_on_shutdown from imbue.mngr.cli.common_opts import add_common_options @@ -78,6 +79,12 @@ # supplied via CLI flag, env var, or ``settings.toml``. _DEFAULT_LATCHKEY_DIRECTORY: Final[Path] = Path("~/.mngr/latchkey") +# How often the supervisor's gateway health check polls the shared +# ``latchkey gateway`` subprocess's liveness. A dead gateway takes all agent +# traffic down, so we detect it quickly; the poll itself is cheap (a +# non-blocking ``poll()`` on the tracked subprocess, no network I/O). +_GATEWAY_HEALTH_CHECK_INTERVAL_SECONDS: Final[float] = 10.0 + # Plugin-config registry key. Must match the name passed to # ``register_plugin_config`` in :mod:`imbue.mngr_latchkey.plugin`. _LATCHKEY_PLUGIN_NAME: Final[str] = "latchkey" @@ -626,9 +633,18 @@ def _run_forward_supervisor( ) logger.info("Waiting for discovery events; send SIGINT or SIGTERM to shut down, SIGHUP to refresh providers.") try: - # Block until shutdown is signalled. The signal handler sets the - # event from any thread, including the main thread itself. - shutdown_event.wait() + # Block until shutdown is signalled, meanwhile supervising the shared gateway + # subprocess: if it dies mid-session (leaving discovery + reverse tunnels up, + # so minds' discovery-freshness watchdog never notices), respawn it on its + # original port before agent traffic stays broken. This runs on the main + # thread, which would otherwise just block on ``shutdown_event``, so it needs + # no thread of its own; the loop returns as soon as shutdown is signalled. + _run_gateway_health_check_loop( + shutdown_event=shutdown_event, + latchkey=latchkey, + concurrency_group=mngr_ctx.concurrency_group, + health_check_interval_seconds=_GATEWAY_HEALTH_CHECK_INTERVAL_SECONDS, + ) finally: logger.info("Shutting down: terminating mngr observe, reverse tunnels, and shared gateway.") # Wake the SIGHUP watcher so it observes ``shutdown_event`` and exits; @@ -640,7 +656,7 @@ def _run_forward_supervisor( try: latchkey.stop_gateway() except LatchkeyError as e: - logger.warning("Failed to stop shared Latchkey gateway during shutdown: {}", e) + logger.opt(exception=e).error("Failed to stop shared Latchkey gateway during shutdown.") delete_forward_info(latchkey.plugin_data_dir) @@ -720,6 +736,46 @@ def _run_sighup_bounce_watcher( logger.opt(exception=e).error("SIGHUP observe bounce failed; continuing to watch for further bounces") +def _run_gateway_health_check_loop( + shutdown_event: threading.Event, + latchkey: Latchkey, + concurrency_group: ConcurrencyGroup, + health_check_interval_seconds: float, +) -> None: + """Loop until shutdown: respawn the shared gateway whenever its subprocess has died. + + ``mngr latchkey forward`` owns the single shared ``latchkey gateway``. If that + subprocess crashes mid-session, discovery and the reverse tunnels stay up -- so + minds' discovery-freshness watchdog reads the pipeline as healthy -- while every + agent's traffic to the gateway silently fails. This loop is the supervisor-side + counterpart to that watchdog: it polls the gateway's liveness on a fixed cadence + and respawns it (reusing its previous port, so the reverse tunnels and the + published ``gateway_port`` stay valid) the moment it finds it dead. + + A single respawn failure never tears the loop down: it is logged and the loop + keeps polling, so a transient failure is retried on the next tick. + """ + while not shutdown_event.is_set(): + # Wait first, then check: the gateway was started eagerly just before this + # loop, so the first liveness check is due one interval later. Returns + # promptly when shutdown is signalled during the wait. + if shutdown_event.wait(timeout=health_check_interval_seconds): + return + if latchkey.is_gateway_running: + continue + logger.info("Shared Latchkey gateway subprocess is not running; respawning it.") + try: + gateway_port = latchkey.start_gateway(concurrency_group) + except LatchkeyError as e: + logger.opt(exception=e).error("Failed to respawn shared Latchkey gateway; will retry on the next check.") + continue + logger.info("Respawned shared Latchkey gateway at http://{}:{}", latchkey.listen_host, gateway_port) + try: + update_forward_info_gateway_port(latchkey.plugin_data_dir, gateway_port) + except LatchkeyStoreError as e: + logger.opt(exception=e).error("Failed to publish respawned gateway port.") + + def _install_signal_handlers(shutdown_event: threading.Event, bounce_event: threading.Event) -> None: """Wire SIGINT / SIGTERM to shutdown and SIGHUP to an observe bounce. @@ -754,7 +810,10 @@ def _install_signal_handlers(shutdown_event: threading.Event, bounce_event: thre 1. Initializes the configured ``Latchkey`` (version-checks the binary, adopts or discards any pre-existing detached gateway record). -2. Eagerly spawns the shared ``latchkey gateway`` subprocess. +2. Eagerly spawns the shared ``latchkey gateway`` subprocess and + supervises it: a background health check respawns it (reusing its + original port) if the subprocess dies mid-session, so a crashed + gateway does not silently take agent traffic down. 3. Spawns ``mngr observe --discovery-only --quiet`` and, for every agent discovered, opens a reverse SSH tunnel that bridges the agent's ``127.0.0.1:AGENT_SIDE_LATCHKEY_PORT`` to the host-side diff --git a/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/core.py b/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/core.py index 51d0a33f..31d39d24 100644 --- a/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/core.py +++ b/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/core.py @@ -103,7 +103,7 @@ # ``LATCHKEY_VERSION``). 2.18.0 was the first release with the ``auth prepare`` # subcommand, which the Minds Google OAuth flow (:meth:`Latchkey.auth_prepare`) # depends on. -LATCHKEY_MIN_VERSION: Final[str] = "2.19.1" +LATCHKEY_MIN_VERSION: Final[str] = "2.20.2" # Fixed port that every containerized/VM/VPS agent sees on its own 127.0.0.1 # when reaching the Latchkey gateway. A per-agent SSH reverse tunnel bridges @@ -599,22 +599,26 @@ def start_gateway(self, concurrency_group: ConcurrencyGroup) -> int: Pair the returned port with :attr:`listen_host` to build the gateway URL (``http://:``). """ - # Fast path: already running. + # Fast path: already running and its subprocess is still alive. with self._lock: self._require_initialized_locked() running = self._running_gateway - if running is not None: - return running.port + if running is not None and running.process.poll() is None: + return running.port plugin_dir = self.plugin_data_dir - # Slow path: serialize spawning. Double-check after acquiring - # the spawn lock so a concurrent caller that already spawned - # is observed before we duplicate the work. + # Slow path: serialize spawning. Double-check after acquiring the spawn + # lock so a concurrent caller that already (re)spawned is observed before + # we duplicate the work. A dead cached gateway (its subprocess exited -- + # e.g. it crashed mid-session) is respawned here rather than returning its + # stale port; its previously-bound port is reused so agent reverse tunnels + # and the published ``gateway_port`` stay valid across the restart. with self._spawn_lock: with self._lock: running = self._running_gateway - if running is not None: - return running.port - port, process = self._spawn_gateway(concurrency_group, plugin_dir) + if running is not None and running.process.poll() is None: + return running.port + preferred_port = running.port if running is not None else None + port, process = self._spawn_gateway(concurrency_group, plugin_dir, preferred_port=preferred_port) with self._lock: self._running_gateway = _RunningGateway(port=port, process=process) return port @@ -652,9 +656,15 @@ def stop_gateway(self) -> None: @property def is_gateway_running(self) -> bool: - """Whether this :class:`Latchkey` has spawned a gateway and not yet stopped it.""" + """Whether this :class:`Latchkey` has a spawned gateway whose subprocess is still alive. + + Checks actual subprocess liveness (via ``poll()``), not merely the presence + of a tracked record, so a gateway that exited unexpectedly reads as + not-running and the supervisor's gateway health check can respawn it. + """ with self._lock: - return self._running_gateway is not None + running = self._running_gateway + return running is not None and running.process.poll() is None # -- Password / JWT derivation ------------------------------------------ @@ -1158,6 +1168,11 @@ def _spawn_gateway( self, concurrency_group: ConcurrencyGroup, plugin_dir: Path, + # When set, bind the gateway to this exact port instead of allocating a + # fresh one -- used when respawning a crashed gateway so its port (and + # thus every agent reverse tunnel plus the published ``gateway_port``) is + # preserved across the restart. + preferred_port: int | None, ) -> tuple[int, RunningProcess]: """Spawn a fresh ``latchkey gateway`` and return its listen port + :class:`RunningProcess`. @@ -1200,7 +1215,9 @@ def _spawn_gateway( # so a package upgrade overrides any stale on-disk copy. _materialize_bundled_extensions(self.latchkey_directory) - port = _allocate_free_port(self.listen_host) + # Reuse the previously-bound port when respawning a dead gateway; otherwise + # allocate a fresh free port for the first spawn. + port = preferred_port if preferred_port is not None else _allocate_free_port(self.listen_host) env = _build_gateway_env( listen_host=self.listen_host, listen_port=port, @@ -1221,6 +1238,13 @@ def _spawn_gateway( command=[self.latchkey_binary, "gateway", "--max-body-size", str(GATEWAY_MAX_BODY_SIZE_BYTES)], env=env, on_output=_log_gateway_output_line, + # The supervisor's own gateway health check owns respawning a dead + # gateway, so this is not a group-checked strand: were it checked, a + # mid-session crash (non-zero exit) would make every subsequent + # concurrency-group call -- including the ``run_process_in_background`` + # of the respawn itself -- raise on the failed strand, blocking the + # very restart we want. + is_checked_by_group=False, ) except (ConcurrencyExceptionGroup, OSError) as e: raise LatchkeyError(f"Failed to spawn shared Latchkey gateway: {e}") from e diff --git a/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/core_test.py b/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/core_test.py index 2616de26..4a6bf102 100644 --- a/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/core_test.py +++ b/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/core_test.py @@ -20,6 +20,7 @@ from imbue.mngr_forward.ssh_tunnel import RemoteSSHInfo from imbue.mngr_forward.ssh_tunnel import SSHTunnelError from imbue.mngr_forward.ssh_tunnel import SSHTunnelManager +from imbue.mngr_latchkey.cli import _run_gateway_health_check_loop from imbue.mngr_latchkey.core import AGENT_SIDE_LATCHKEY_PORT from imbue.mngr_latchkey.core import CredentialStatus from imbue.mngr_latchkey.core import LATCHKEY_MIN_VERSION @@ -593,6 +594,94 @@ def test_stop_gateway_clears_in_memory_state(tmp_path: Path) -> None: manager.stop_gateway() +def test_is_gateway_running_reflects_subprocess_liveness(tmp_path: Path) -> None: + """``is_gateway_running`` must track actual subprocess liveness, not just a tracked record. + + A gateway whose subprocess exited unexpectedly (a crash, with no + ``stop_gateway`` to clear the record) must read as not-running so the + supervisor's gateway health check knows to respawn it. + """ + fake_binary = _make_fake_latchkey_binary(tmp_path) + manager = Latchkey(latchkey_directory=tmp_path, latchkey_binary=str(fake_binary)) + manager.initialize() + with ConcurrencyGroup(name=f"test-{uuid4().hex}") as cg: + port = manager.start_gateway(cg) + assert manager.is_gateway_running + assert _wait_for_listening("127.0.0.1", port) + + # Simulate an unexpected gateway death: kill the subprocess directly, + # leaving the tracked record in place (unlike ``stop_gateway``). + running = manager._running_gateway + assert running is not None + running.process.terminate() + + assert not manager.is_gateway_running + manager.stop_gateway() + + +def test_start_gateway_respawns_dead_gateway_on_same_port(tmp_path: Path) -> None: + """A crashed gateway is respawned on its original port so agent reverse tunnels stay valid.""" + fake_binary = _make_fake_latchkey_binary(tmp_path) + manager = Latchkey(latchkey_directory=tmp_path, latchkey_binary=str(fake_binary)) + manager.initialize() + with ConcurrencyGroup(name=f"test-{uuid4().hex}") as cg: + original_port = manager.start_gateway(cg) + assert _wait_for_listening("127.0.0.1", original_port) + running_before = manager._running_gateway + assert running_before is not None + first_process = running_before.process + + # Kill the subprocess to simulate a crash, then re-ensure the gateway. + first_process.terminate() + assert not manager.is_gateway_running + + respawned_port = manager.start_gateway(cg) + assert respawned_port == original_port + assert manager.is_gateway_running + running_after = manager._running_gateway + assert running_after is not None + assert running_after.process is not first_process + assert _wait_for_listening("127.0.0.1", respawned_port) + manager.stop_gateway() + + +def test_gateway_health_check_loop_respawns_dead_gateway(tmp_path: Path) -> None: + """The supervisor's gateway health-check loop respawns a crashed gateway on its original port.""" + fake_binary = _make_fake_latchkey_binary(tmp_path) + manager = Latchkey(latchkey_directory=tmp_path, latchkey_binary=str(fake_binary)) + manager.initialize() + with ConcurrencyGroup(name=f"test-{uuid4().hex}") as cg: + original_port = manager.start_gateway(cg) + assert _wait_for_listening("127.0.0.1", original_port) + + # Run the health-check loop in the background on a fast cadence. + shutdown_event = threading.Event() + loop_thread = threading.Thread( + target=_run_gateway_health_check_loop, + args=(shutdown_event, manager, cg, 0.05), + name="test-gateway-health-check", + daemon=True, + ) + loop_thread.start() + try: + # Simulate a crash: kill the subprocess, leaving the tracked record in place. + running = manager._running_gateway + assert running is not None + running.process.terminate() + + # The loop must notice and respawn the gateway on the same port. + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline and not manager.is_gateway_running: + threading.Event().wait(timeout=_POLL_INTERVAL_SECONDS) + assert manager.is_gateway_running + assert _wait_for_listening("127.0.0.1", original_port) + finally: + shutdown_event.set() + loop_thread.join(timeout=5.0) + assert not loop_thread.is_alive() + manager.stop_gateway() + + def test_stop_gateway_is_no_op_when_not_running(tmp_path: Path) -> None: fake_binary = _make_fake_latchkey_binary(tmp_path) manager = Latchkey(latchkey_directory=tmp_path, latchkey_binary=str(fake_binary)) diff --git a/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/discovery.py b/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/discovery.py index eb05cccf..47588b45 100644 --- a/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/discovery.py +++ b/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/discovery.py @@ -387,11 +387,19 @@ def _run_remote_gateway_provisioning( # credentials/permissions in sync from now on. with self._remote_hosts_lock: self._remote_host_provider_by_id[str(host_id)] = provider_name + # The reverse tunnel runs *on the outer host*, so it needs the + # port the container's sshd is published on from the outer host's + # own loopback -- not ``ssh_info.port``, which is how a remote + # client reaches the container (a box-forwarded port for slices). + # Providers whose topology splits publish from connect surface the + # loopback port here; otherwise the two coincide and we fall back. + loopback_ssh_port = provider.get_container_loopback_ssh_port(host_id) + container_ssh_port = loopback_ssh_port if loopback_ssh_port is not None else ssh_info.port provision_remote_gateway( outer, host_id=host_id, container_ssh_user=ssh_info.user, - container_ssh_port=ssh_info.port, + container_ssh_port=container_ssh_port, latchkey_directory=self.latchkey.latchkey_directory, gateway_password=self.latchkey.derive_gateway_password(), ) diff --git a/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/extensions/services.json b/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/extensions/services.json index 37074a29..caf92677 100644 --- a/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/extensions/services.json +++ b/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/extensions/services.json @@ -141,7 +141,7 @@ { "scope": "github-rest-api", "display_name": "GitHub (REST API)", - "description": "Any interaction with the GitHub REST API, excluding GraphQL.", + "description": "Any interaction with the GitHub REST API.", "permissions": [ { "name": "github-read-all", @@ -201,6 +201,12 @@ } ] }, + { + "scope": "github-graphql-api", + "display_name": "GitHub (GraphQL API)", + "description": "Any interaction with the GitHub GraphQL API.", + "permissions": [] + }, { "scope": "github-git", "display_name": "GitHub (git)", diff --git a/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/remote_gateway.py b/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/remote_gateway.py index c9a6fac8..a0e208de 100644 --- a/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/remote_gateway.py +++ b/vendor/mngr/libs/mngr_latchkey/imbue/mngr_latchkey/remote_gateway.py @@ -1,12 +1,30 @@ """Provision the latchkey CLI (and its runtime prerequisites) on a remote VPS. -This is the first piece of "run the latchkey gateway *on* the VPS" support. Where the rest of the package reverse-tunnels a desktop-side gateway into each agent, this module installs the upstream ``latchkey`` CLI directly on -the agent's outer host (the VPS) so a gateway can eventually be run there. +the agent's outer host (the VPS) and runs a gateway there. + +The VPS-resident gateway and the VPS->container reverse SSH tunnel are both +long-running processes that must survive crashes and VM pause/resume. Rather +than spawn them detached (``nohup`` + a PID-file guard), they are registered as +``supervisord`` programs: ``supervisord`` is installed from the distro package +and auto-restarts either process if it dies. The SSH tunnel additionally +carries keepalive flags so a connection wedged by a paused-then-resumed VM is +detected and torn down, letting ``supervisord`` restart it. + +Crash recovery deliberately stops short of surviving a full *reboot*: the +gateway's secrets (the encryption key and the derived listen password) are +kept in a tmpfs directory under ``/run``, which is RAM-backed and so survives +process crashes -- letting ``supervisord`` restart the gateway without a +desktop round-trip -- but is wiped by a reboot. This is a deliberate choice to +never persist the encryption key on the VPS disk beside the encrypted +credential store (which would be equivalent to storing the credentials in +plaintext from a disk-snapshot threat model). Provisioning verifies the +directory really is RAM-backed and refuses to proceed otherwise, so the key is +never written to a disk filesystem by mistake. After a reboot the gateway +stays down until the next provisioning pass re-writes the secrets. """ -import secrets import shlex import tempfile import time @@ -34,7 +52,7 @@ from imbue.mngr_latchkey.store import plugin_data_dir # Version of the upstream ``latchkey`` CLI to install on the VPS. -LATCHKEY_VERSION: Final[str] = "2.20.0" +LATCHKEY_VERSION: Final[str] = "2.20.2" # Port inside the container on which the VPS-resident gateway is reachable (the # VPS->container reverse tunnel binds it). Deliberately distinct from @@ -87,13 +105,98 @@ # 0600 matches the local ``save_permissions`` chmod and keeps secrets private. _REMOTE_FILE_MODE: Final[str] = "0600" -# Filenames (under the remote ``$HOME/.latchkey`` directory) for the detached -# gateway and reverse-tunnel processes: their stdout/stderr logs and the PID -# files their idempotency checks read. +# Filenames (under the remote ``$HOME/.latchkey`` directory) for the +# supervisord-managed gateway and reverse-tunnel programs' stdout/stderr logs. _REMOTE_GATEWAY_LOG_FILENAME: Final[str] = "gateway.log" -_REMOTE_GATEWAY_PID_FILENAME: Final[str] = "gateway.pid" _REMOTE_TUNNEL_LOG_FILENAME: Final[str] = "tunnel.log" -_REMOTE_TUNNEL_PID_FILENAME: Final[str] = "tunnel.pid" + +# PID files a *pre-supervisord* build wrote under ``$HOME/.latchkey`` when it +# launched the gateway and reverse tunnel detached via ``nohup``. A VPS +# provisioned by such a build still has those processes alive, holding +# ``OUTER_PORT`` and the container's reverse-forward bind, so provisioning tears +# them down (by PID, cmdline-guarded) before starting the supervisord programs +# (see :func:`_migrate_legacy_remote_gateway_state`). The cmdline markers match +# what the old PID-file guard used to verify each process by. +_LEGACY_GATEWAY_PID_FILENAME: Final[str] = "gateway.pid" +_LEGACY_TUNNEL_PID_FILENAME: Final[str] = "tunnel.pid" +_LEGACY_GATEWAY_CMDLINE_MARKER: Final[str] = "gateway" + +# Filename (under the remote ``$HOME/.latchkey`` directory) for the gateway's +# supervisord launch wrapper. The wrapper is not secret (it only references the +# secret file *paths*), so it lives on the normal disk. +_GATEWAY_RUN_SCRIPT_FILENAME: Final[str] = "gateway_run.sh" + +# tmpfs (RAM-backed) directory holding the gateway's two secrets: the encryption +# key and the derived listen password. ``/run`` is the FHS location for runtime +# state, is root-owned, and is a tmpfs under systemd (which we already require +# for the supervisor service), so it is wiped on reboot -- the key is never +# persisted to the VPS disk beside the encrypted credential store (which would +# be equivalent to storing the credentials in plaintext against a disk-snapshot +# threat model), yet it survives a process crash so supervisord can restart the +# gateway without a desktop round-trip. Provisioning verifies this is really a +# RAM-backed filesystem before writing to it (see +# :func:`_ensure_ram_backed_secrets_dir`). The wrapper reads these 0600 files +# into the environment and execs the gateway, so the secrets never appear in the +# supervisord config or a process listing. +# +# Deliberately not ``/tmp``: unlike ``/run``, ``/tmp`` is not reliably a tmpfs +# (on many distros, incl. common Debian/Ubuntu VPS images, it is a normal +# directory on the root disk and is cleaned by age rather than wiped on boot), +# so the key could land on -- and survive on -- persistent disk. ``/tmp`` is +# also world-writable (1777), exposing the classic hostile-symlink attack that a +# root-owned ``/run`` avoids. +_TMPFS_SECRETS_DIR: Final[Path] = Path("/run/mngr-latchkey") + +# Filesystem types (as reported by ``stat -f -c %T``) we accept as RAM-backed +# for the secrets directory. Anything else means the key would land on disk. +_RAM_BACKED_FILESYSTEM_TYPES: Final[frozenset[str]] = frozenset({"tmpfs", "ramfs"}) +_GATEWAY_ENCRYPTION_KEY_FILENAME: Final[str] = "gateway_encryption_key" +_GATEWAY_PASSWORD_FILENAME: Final[str] = "gateway_listen_password" + +# supervisord drop-in program directory (the distro ``supervisor`` package's +# ``supervisord.conf`` includes ``conf.d/*.conf``) and the program names / +# config filenames for the gateway and the reverse tunnel. +_SUPERVISOR_CONFD_DIR: Final[Path] = Path("/etc/supervisor/conf.d") +_GATEWAY_PROGRAM_NAME: Final[str] = "latchkey-gateway" +_TUNNEL_PROGRAM_NAME: Final[str] = "latchkey-tunnel" +_GATEWAY_CONF_FILENAME: Final[str] = f"{_GATEWAY_PROGRAM_NAME}.conf" +_TUNNEL_CONF_FILENAME: Final[str] = f"{_TUNNEL_PROGRAM_NAME}.conf" + +# Absolute paths to the interpreters/binaries named in supervisord ``command=`` +# lines. supervisord resolves a program via its *own* PATH (not the program's +# environment), so an absolute path is the robust choice; both are the fixed +# Debian locations. +_SH_BINARY_PATH: Final[str] = "/bin/sh" +_SSH_BINARY_PATH: Final[str] = "/usr/bin/ssh" + +# supervisord program tuning. ``startsecs`` is how long a program must stay up +# to count as successfully started. The tunnel uses a huge ``startretries`` so +# supervisord keeps retrying while the container's sshd is still coming up +# (or a stale remote bind clears) instead of giving up. The gateway uses a +# small ``startretries``: the only expected repeated start failure is a reboot +# (its tmpfs secrets are gone), where going quietly FATAL is the desired +# "down until re-provisioned" state rather than an endless retry loop. Logs are +# size-rotated by supervisord itself. +_SUPERVISOR_START_SECONDS: Final[int] = 5 +_SUPERVISOR_GATEWAY_START_RETRIES: Final[int] = 3 +_SUPERVISOR_TUNNEL_START_RETRIES: Final[int] = 1_000_000 +_SUPERVISOR_LOG_MAX_BYTES: Final[str] = "10MB" +_SUPERVISOR_LOG_BACKUPS: Final[int] = 3 + +# ``supervisorctl reread && update`` starts the freshly-written programs; it can +# take a beat to actually launch them, so allow more than the quick-command +# budget. +_SUPERVISOR_COMMAND_TIMEOUT_SECONDS: Final[float] = 60.0 + +# SSH keepalive tuning for the reverse tunnel. Without these, a tunnel whose +# far end vanished (e.g. the VM was paused for a week and resumed) can hang +# indefinitely on a dead TCP connection. ``ServerAliveInterval`` / +# ``ServerAliveCountMax`` make ssh probe the peer every N seconds and exit after +# a few unanswered probes, at which point supervisord restarts it; a bounded +# ``ConnectTimeout`` keeps a stalled *initial* connect from wedging the restart. +_SSH_SERVER_ALIVE_INTERVAL_SECONDS: Final[int] = 30 +_SSH_SERVER_ALIVE_COUNT_MAX: Final[int] = 3 +_SSH_CONNECT_TIMEOUT_SECONDS: Final[int] = 15 # Filename of the ad-hoc private key generated on the VPS for the # outer-host -> container SSH used by the reverse tunnel. Lives under the @@ -113,13 +216,16 @@ class RemoteGatewayError(LatchkeyError, RuntimeError): def _build_ensure_installed_script(latchkey_version: str, node_major_version: str) -> str: - """Build an idempotent POSIX-sh script that installs curl, Node.js, and latchkey. + """Build an idempotent POSIX-sh script that installs curl, Node.js, supervisor, and latchkey. Each component is gated behind a presence check so a re-run on an already-provisioned VPS does no install work. The script avoids ``pipefail`` (unsupported by Debian's default ``/bin/sh``, dash) by downloading the NodeSource setup script to a file instead of piping it, - so ``set -e`` still aborts on a failed download. + so ``set -e`` still aborts on a failed download. supervisord is installed + (and its init service started) so the gateway and reverse tunnel can be run + as auto-restarting programs that recover from crashes without a desktop + round-trip. """ nodesource_url = f"https://deb.nodesource.com/setup_{node_major_version}.x" return "\n".join( @@ -139,6 +245,18 @@ def _build_ensure_installed_script(latchkey_version: str, node_major_version: st " apt-get install -y nodejs", " rm -f /tmp/nodesource_setup.sh", "fi", + # supervisor: supervises the gateway + tunnel and restarts either on + # crash. + "if ! command -v supervisord >/dev/null 2>&1; then", + " apt-get update", + " apt-get install -y supervisor", + "fi", + # Ensure supervisord is running now (the distro package starts it on + # install, but repeating it is idempotent and recovers a host where + # the service was left stopped). Tolerated on the rare non-systemd + # host: the reread/update below then fails loudly instead of + # silently degrading. + "systemctl enable --now supervisor >/dev/null 2>&1 || true", # latchkey CLI, pinned to the exact version. Reinstall whenever the # installed version differs (missing latchkey reports an empty string). f'if [ "$(LATCHKEY_DISABLE_COUNTING=1 latchkey --version 2>/dev/null | sed \'s/^v//\')" != "{latchkey_version}" ]; then', @@ -149,7 +267,7 @@ def _build_ensure_installed_script(latchkey_version: str, node_major_version: st def _ensure_latchkey_installed(host: OuterHostInterface) -> None: - """Ensure curl, Node.js, and the pinned latchkey CLI are installed on the VPS. + """Ensure curl, Node.js, supervisor, and the pinned latchkey CLI are installed on the VPS. Idempotent: each component is installed only when missing (or, for latchkey, when the installed version differs from :data:`LATCHKEY_VERSION`). @@ -351,58 +469,135 @@ def sync_permissions(host: OuterHostInterface, latchkey_directory: Path, host_id host.write_file(remote_path, content.encode("utf-8"), mode=_REMOTE_FILE_MODE, is_atomic=True) -def _pidfile_guarded_launch_script(pid_filename: str, cmdline_marker: str, launch_command: str) -> str: - """Build an idempotent background launch keyed off a PID file under ``$HOME/.latchkey``. +def _build_supervisor_program_config(program_name: str, command: str, log_path: str, start_retries: int) -> str: + """Build a supervisord ``[program:...]`` drop-in config for a long-running process. + + ``autostart``/``autorestart`` make supervisord launch the program and + relaunch it whenever it exits, so a crashed process is brought back without + a desktop round-trip. ``start_retries`` bounds how many times supervisord + retries a program that keeps failing to *start* before marking it FATAL + (the tunnel wants a large value while the container's sshd comes up; the + gateway wants a small one, since a repeated start failure means its tmpfs + secrets are gone after a reboot and going quietly FATAL is the desired + state). ``stopasgroup``/``killasgroup`` ensure a stop/restart tears down the + whole process group (any ssh or child), and supervisord size-rotates the + combined stdout+stderr into ``log_path``. + + ``command`` must already be shell-quoted by the caller (e.g. via + ``shlex.quote`` on any token with spaces): supervisord shell-splits it with + ``shlex.split``, which round-trips ``shlex.quote``'s output. Separately, + supervisord expands ``%(...)s`` sequences in *every* config value before + that split, so a literal ``%`` in the command or log path must be doubled to + ``%%`` or the config fails to parse (``shlex.quote`` does not do this -- it + treats ``%`` as safe). We double it here so an exotic path can never break + config loading. + """ + # supervisord runs %(...)s interpolation on each value; escape literal + # percent signs so they survive to the shell-split argv unchanged. + escaped_command = command.replace("%", "%%") + escaped_log_path = log_path.replace("%", "%%") + return "\n".join( + ( + f"[program:{program_name}]", + f"command={escaped_command}", + "user=root", + "autostart=true", + "autorestart=true", + f"startsecs={_SUPERVISOR_START_SECONDS}", + f"startretries={start_retries}", + "stopasgroup=true", + "killasgroup=true", + f"stdout_logfile={escaped_log_path}", + f"stdout_logfile_maxbytes={_SUPERVISOR_LOG_MAX_BYTES}", + f"stdout_logfile_backups={_SUPERVISOR_LOG_BACKUPS}", + "redirect_stderr=true", + "", + ) + ) + - Skips the launch when the PID recorded in ``$HOME/.latchkey/`` - is still alive *and* its ``/proc//cmdline`` contains ``cmdline_marker`` - (the marker check guards a stale PID file whose number got reused). - Otherwise it runs ``launch_command`` in the background and records the new - PID via ``$!``. +def _reload_supervisor_programs(host: OuterHostInterface, host_name: str, program_name: str) -> None: + """Apply a freshly-written supervisord drop-in and ensure ``program_name`` is running. - A PID file is used deliberately instead of ``pgrep -f``: this script runs as - ``sh -c ''`` on the VPS, whose own argv therefore contains - ``launch_command``, so a ``pgrep -f`` for the process would match the shell - running this very script and wrongly conclude the process is already up. - Inspecting one specific PID cannot self-match, and ``kill -0`` / ``/proc`` - need no ``procps``. + ``reread`` reloads the config files and ``update`` (re)starts new/changed + programs and stops removed ones -- idempotent, so an unchanged config never + needlessly bounces a healthy program. ``update`` does not, however, restart + a program that is merely STOPPED/FATAL (e.g. a gateway that went FATAL after + a reboot wiped its tmpfs secrets) when its config is unchanged, so a + best-effort ``start`` follows: it brings such a program back up with the + freshly-written secrets, and is a harmless no-op (``|| true``) when the + program is already running. ``program_name`` is a fixed internal literal, so + it is interpolated directly. Raises :class:`RemoteGatewayError` if the + reread/update fails. """ - return "\n".join( + result = host.execute_idempotent_command( + f"supervisorctl reread && supervisorctl update && (supervisorctl start {program_name} || true)", + timeout_seconds=_SUPERVISOR_COMMAND_TIMEOUT_SECONDS, + ) + if not result.success: + raise RemoteGatewayError( + "Failed to reload supervisor programs on VPS {}: {}".format( + host_name, result.stderr.strip() or result.stdout.strip() + ) + ) + + +def _ensure_ram_backed_secrets_dir(host: OuterHostInterface, host_name: str) -> None: + """Create the tmpfs secrets directory (0700) and verify it is genuinely RAM-backed. + + The gateway's encryption key must never land on a disk-backed filesystem, so + this creates :data:`_TMPFS_SECRETS_DIR` and checks (via ``stat -f``) that its + filesystem type is one of :data:`_RAM_BACKED_FILESYSTEM_TYPES`. If the + directory is not RAM-backed -- e.g. ``/run`` is unexpectedly not a tmpfs on + some host -- it refuses to proceed rather than silently persisting the key, + so the caller never writes the secret to disk. Raises + :class:`RemoteGatewayError` if creation or the verification fails. + """ + secrets_dir_q = shlex.quote(str(_TMPFS_SECRETS_DIR)) + # ``$_fstype`` must not be *any* of the accepted RAM-backed types. + not_ram_backed_condition = " && ".join( + f'[ "$_fstype" != {shlex.quote(fstype)} ]' for fstype in sorted(_RAM_BACKED_FILESYSTEM_TYPES) + ) + script = "\n".join( ( "set -e", - 'mkdir -p "$HOME/.latchkey"', - f'_pidfile="$HOME/.latchkey/{pid_filename}"', - 'if [ -f "$_pidfile" ] && _pid="$(cat "$_pidfile" 2>/dev/null)" && [ -n "$_pid" ] && ' - f'kill -0 "$_pid" 2>/dev/null && grep -qaF {shlex.quote(cmdline_marker)} "/proc/$_pid/cmdline" 2>/dev/null; then', - " exit 0", + f"mkdir -p {secrets_dir_q}", + f"chmod 700 {secrets_dir_q}", + f'_fstype="$(stat -f -c %T {secrets_dir_q})"', + f"if {not_ram_backed_condition}; then", + f' echo "refusing to store the latchkey encryption key: {_TMPFS_SECRETS_DIR} is on a ' + '$_fstype filesystem, not RAM-backed (tmpfs/ramfs)" >&2', + " exit 1", "fi", - f"{launch_command} &", - 'echo $! > "$_pidfile"', - "exit 0", ) ) + result = host.execute_idempotent_command(script, timeout_seconds=_REMOTE_COMMAND_TIMEOUT_SECONDS) + if not result.success: + raise RemoteGatewayError( + "Could not prepare a RAM-backed secrets directory ({}) on VPS {}: {}".format( + _TMPFS_SECRETS_DIR, host_name, result.stderr.strip() or result.stdout.strip() + ) + ) -def _build_gateway_start_script(outer_port: int, key_file_path: Path, password_file_path: Path) -> str: - """Build a script that starts a detached ``latchkey gateway`` unless one is already running. +def _build_gateway_run_script(outer_port: int, key_file_path: Path, password_file_path: Path) -> str: + """Build the wrapper script supervisord runs to launch ``latchkey gateway``. - Launches the gateway under ``nohup`` with stdio detached so it outlives the - SSH session that started it, logging to ``$HOME/.latchkey/gateway.log``. The - gateway binds ``outer_port`` on the VPS loopback only -- it is reached from - the container via the reverse tunnel, never exposed off-host. Idempotency is - via a PID file (see :func:`_pidfile_guarded_launch_script`). + supervisord invokes this as ``/bin/sh