From 97c3324855ac5114827db2fd3c7ad5271939e707 Mon Sep 17 00:00:00 2001 From: Andrew Anderson Date: Sun, 25 Oct 2026 08:40:08 -0400 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20bootstrap:=20CI,=20release=20pi?= =?UTF-8?q?peline=20and=20first=20sync=20of=20hive=20src/crux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolding maintained in this repository: - .github/workflows/ci.yml: build/vet/test (-short -race), golangci-lint (hive's enabled set), crux serve smoke on linux+macOS, goreleaser check - .github/workflows/sync-guard.yml: refuses PRs touching Go code; source of truth is hivecommons/hive src/crux - .github/workflows/release.yml + .goreleaser.yaml: on tag v*, darwin/linux x amd64/arm64 archives, cosign-signed checksums, GitHub release, multi-arch ghcr.io/hivecommons/crux image (alpine + tmux) - .github/scripts/smoke.sh, Dockerfile, .golangci.yml Everything else is the first publish of hivecommons/hive src/crux (see .hive-sync) with the module path rewritten to github.com/hivecommons/crux by hive's src/scripts/crux-publish.sh. Signed-off-by: Andrew Anderson --- .dockerignore | 5 + .github/scripts/smoke.sh | 58 + .github/workflows/ci.yml | 146 + .github/workflows/release.yml | 77 + .github/workflows/sync-guard.yml | 63 + .gitignore | 4 + .golangci.yml | 27 + .goreleaser.yaml | 115 + .hive-sync | 8 + Dockerfile | 24 + Dockerfile.goreleaser | 12 + activity.go | 13 + agent/.hive-agent-stubs-1969186452/bob | 2 + agent/.hive-agent-stubs-1969186452/claude | 2 + agent/.hive-agent-stubs-1969186452/copilot | 2 + agent/.hive-agent-stubs-1969186452/gemini | 2 + agent/.hive-agent-stubs-1969186452/goose | 2 + .../perm/.hive-bootstrap-cxa.txt | 1 + .../perm/.hive-caps-a | 0 .../perm/.hive-caps-agent | 0 .../perm/.hive-caps-bad | 0 .../perm/.hive-caps-covdiag | 0 .../perm/.hive-caps-covhang | 0 .../perm/.hive-caps-cxa | 0 .../perm/.hive-caps-env-test | 0 .../perm/.hive-caps-guide | 0 .../perm/.hive-caps-inf | 0 .../perm/.hive-caps-inference-agent | 0 .../perm/.hive-caps-kdfires | 0 .../perm/.hive-caps-nomodel | 0 .../perm/.hive-caps-quality | 0 .../perm/.hive-caps-reviewer | 0 .../perm/.hive-caps-scanner | 0 .../perm/.hive-caps-schema | 0 .../perm/.hive-caps-sec-check | 0 .../perm/.hive-caps-sub | 0 .../perm/.hive-caps-test | 0 .../perm/.hive-caps-test-agent | 0 .../perm/.hive-caps-tester | 0 .../perm/.hive-caps-worker | 0 .../perm/.hive-mode-a | 1 + .../perm/.hive-mode-agent | 1 + .../perm/.hive-mode-bad | 1 + .../perm/.hive-mode-covdiag | 1 + .../perm/.hive-mode-covhang | 1 + .../perm/.hive-mode-cxa | 1 + .../perm/.hive-mode-env-test | 1 + .../perm/.hive-mode-guide | 1 + .../perm/.hive-mode-inf | 1 + .../perm/.hive-mode-inference-agent | 1 + .../perm/.hive-mode-kdfires | 1 + .../perm/.hive-mode-nomodel | 1 + .../perm/.hive-mode-quality | 1 + .../perm/.hive-mode-reviewer | 1 + .../perm/.hive-mode-scanner | 1 + .../perm/.hive-mode-schema | 1 + .../perm/.hive-mode-sec-check | 1 + .../perm/.hive-mode-sub | 1 + .../perm/.hive-mode-test | 1 + .../perm/.hive-mode-test-agent | 1 + .../perm/.hive-mode-tester | 1 + .../perm/.hive-mode-worker | 1 + .../pluk/logs/hive-a-never-created.jsonl | 5 + .../pluk/logs/hive-a.jsonl | 36 + .../pluk/logs/hive-bad.jsonl | 1 + .../pluk/logs/hive-covdiag.jsonl | 1 + .../pluk/logs/hive-covhang.jsonl | 1 + .../pluk/logs/hive-cxa.jsonl | 42 + .../pluk/logs/hive-kdfires.jsonl | 1 + .../pluk/logs/hive-reviewer.jsonl | 1 + .../pluk/logs/hive-scanner-kicklog-test.jsonl | 0 .../pluk/logs/hive-scanner.jsonl | 38 + .../pluk/logs/hive-worker.jsonl | 77 + agent/.hive-agent-stubs-6766624/bob | 2 + agent/.hive-agent-stubs-6766624/claude | 2 + agent/.hive-agent-stubs-6766624/copilot | 2 + agent/.hive-agent-stubs-6766624/gemini | 2 + agent/.hive-agent-stubs-6766624/goose | 2 + .../perm/.hive-bootstrap-cxa.txt | 1 + .../perm/.hive-caps-a | 0 .../perm/.hive-caps-agent | 0 .../perm/.hive-caps-bad | 0 .../perm/.hive-caps-covdiag | 0 .../perm/.hive-caps-cxa | 0 .../perm/.hive-caps-env-test | 0 .../perm/.hive-caps-guide | 0 .../perm/.hive-caps-inf | 0 .../perm/.hive-caps-inference-agent | 0 .../perm/.hive-caps-kdfires | 0 .../perm/.hive-caps-nomodel | 0 .../perm/.hive-caps-quality | 0 .../perm/.hive-caps-reviewer | 0 .../perm/.hive-caps-scanner | 0 .../perm/.hive-caps-schema | 0 .../perm/.hive-caps-sec-check | 0 .../perm/.hive-caps-sub | 0 .../perm/.hive-caps-test | 0 .../perm/.hive-caps-test-agent | 0 .../perm/.hive-caps-tester | 0 .../perm/.hive-caps-worker | 0 .../perm/.hive-mode-a | 1 + .../perm/.hive-mode-agent | 1 + .../perm/.hive-mode-bad | 1 + .../perm/.hive-mode-covdiag | 1 + .../perm/.hive-mode-cxa | 1 + .../perm/.hive-mode-env-test | 1 + .../perm/.hive-mode-guide | 1 + .../perm/.hive-mode-inf | 1 + .../perm/.hive-mode-inference-agent | 1 + .../perm/.hive-mode-kdfires | 1 + .../perm/.hive-mode-nomodel | 1 + .../perm/.hive-mode-quality | 1 + .../perm/.hive-mode-reviewer | 1 + .../perm/.hive-mode-scanner | 1 + .../perm/.hive-mode-schema | 1 + .../perm/.hive-mode-sec-check | 1 + .../perm/.hive-mode-sub | 1 + .../perm/.hive-mode-test | 1 + .../perm/.hive-mode-test-agent | 1 + .../perm/.hive-mode-tester | 1 + .../perm/.hive-mode-worker | 1 + .../pluk/logs/hive-a-never-created.jsonl | 6 + .../pluk/logs/hive-a.jsonl | 22 + .../pluk/logs/hive-bad.jsonl | 1 + .../pluk/logs/hive-covdiag.jsonl | 1 + .../pluk/logs/hive-cxa.jsonl | 38 + .../pluk/logs/hive-kdfires.jsonl | 1 + .../pluk/logs/hive-reviewer.jsonl | 1 + .../pluk/logs/hive-scanner-kicklog-test.jsonl | 0 .../pluk/logs/hive-scanner.jsonl | 23 + .../pluk/logs/hive-worker.jsonl | 44 + agent/acmm_gate.go | 10 + agent/agent_bootstrap_test.go | 476 +++ agent/agent_capabilities_test.go | 69 + agent/agent_repo_scope_test.go | 131 + agent/agent_state_file_test.go | 182 + agent/agent_unit_test.go | 740 ++++ agent/agent_working_test.go | 196 + agent/agentspecwire.go | 97 + agent/agentspecwire_test.go | 304 ++ agent/agy_launch_test.go | 172 + agent/agy_restart_gate_test.go | 49 + agent/agy_turn.go | 564 +++ agent/agy_turn_test.go | 360 ++ agent/antigravity_credential_test.go | 101 + agent/antigravity_onboarding_test.go | 58 + agent/antigravity_perm_guard_test.go | 26 + agent/audit.go | 79 + agent/audit_test.go | 263 ++ agent/authorize_issue_open_test.go | 108 + agent/authorize_push_branch_test.go | 94 + agent/authorize_review_request_test.go | 127 + agent/authprobe.go | 415 ++ agent/authprobe_credential_gate_test.go | 198 + agent/authprobe_test.go | 649 +++ agent/backend_auth.go | 143 + agent/backend_auth_test.go | 284 ++ agent/backend_coverage_test.go | 336 ++ agent/backend_launch_cmd_test.go | 174 + agent/backend_resume.go | 597 +++ agent/backend_resume_test.go | 467 +++ agent/blocking_prompt_test.go | 363 ++ agent/bob_display_name_test.go | 68 + agent/bob_key_relaunch_loop_test.go | 132 + agent/bob_key_relaunch_test.go | 339 ++ agent/bob_launch_shell_test.go | 49 + agent/bob_settings.go | 441 ++ agent/bob_settings_test.go | 676 +++ agent/bob_startup_kick_test.go | 311 ++ agent/bob_test.go | 499 +++ agent/branches_coverage_test.go | 533 +++ agent/busy_visibility.go | 541 +++ agent/busy_visibility_test.go | 550 +++ agent/capabilities.go | 102 + agent/caveman_install_test.go | 117 + agent/caveman_pin_test.go | 76 + agent/ci_poll_pr_view_test.go | 47 + agent/ci_poll_prohibition_test.go | 36 + agent/ci_poll_sweep_test.go | 189 + agent/ci_poll_wall_clock_test.go | 221 + agent/claude_credential_paths_test.go | 180 + agent/claude_launch_test.go | 137 + agent/claude_oauth_env_test.go | 156 + agent/claude_remote_control.go | 113 + agent/claude_remote_control_test.go | 168 + agent/claude_session_adopt.go | 175 + agent/claude_session_adopt_fallback_test.go | 264 ++ agent/claude_session_adopt_test.go | 266 ++ agent/claude_session_json_helpers_test.go | 72 + agent/claude_session_state.go | 170 + agent/codex_home_nfs_safety_test.go | 222 + agent/codex_kick_regression_test.go | 60 + agent/codex_launch_test.go | 72 + agent/codex_readiness_test.go | 128 + agent/conditions.go | 40 + agent/conditions_test.go | 125 + agent/config_has_tokens_test.go | 113 + agent/consent_wedge.go | 64 + agent/consent_wedge_test.go | 60 + agent/converse_capability_test.go | 151 + agent/copilot_auth_error_test.go | 37 + agent/copilot_cache_janitor.go | 111 + agent/copilot_cache_janitor_test.go | 92 + agent/copilot_models.go | 11 + agent/copilot_models_test.go | 28 + agent/copilot_question_form.go | 28 + agent/copilot_question_form_test.go | 103 + agent/copilot_session_refresh_test.go | 208 + agent/copilot_session_startdelay_test.go | 25 + agent/copilot_token_login_test.go | 25 + agent/copilot_token_promote_test.go | 567 +++ agent/copilot_token_propagate_test.go | 220 + agent/copilot_token_store.go | 385 ++ agent/coverage_boost_test.go | 1946 +++++++++ agent/crash_coverage_test.go | 147 + agent/credential_watchdog_test.go | 254 ++ agent/credentials_watchdog.go | 255 ++ agent/crux_runtime.go | 17 + agent/crux_runtime_test.go | 97 + agent/dashboard_token_isolation_test.go | 108 + agent/dedup_bench_test.go | 126 + agent/diag_coverage_test.go | 260 ++ agent/dismiss_coverage_test.go | 18 + agent/dismiss_prompts_fake_test.go | 77 + agent/enters_prompt_guard_test.go | 86 + agent/envpairs_coverage_test.go | 82 + agent/explain_mode_test.go | 429 ++ agent/fatal_network_producing_test.go | 95 + agent/fleet_breaker_test.go | 270 ++ agent/gitremotes_coverage_test.go | 66 + agent/helpers_coverage_test.go | 362 ++ agent/host_state_deny_test.go | 103 + agent/interactive_home.go | 621 +++ agent/interactive_home_errors_test.go | 241 ++ agent/interactive_home_gitconfig_test.go | 164 + agent/interactive_home_test.go | 538 +++ agent/invocation_metadata_test.go | 162 + agent/jev_install.go | 110 + agent/jev_install_test.go | 213 + agent/kick_async.go | 346 ++ agent/kick_async_deliver_test.go | 263 ++ agent/kick_async_test.go | 224 + agent/kick_cancel.go | 75 + agent/kick_cancel_test.go | 314 ++ agent/kick_chunk_pause_test.go | 157 + agent/kick_delivery_failure_test.go | 207 + agent/kick_log_alt_screen_test.go | 16 + agent/kick_logs.go | 485 +++ agent/kick_logs_dir_test.go | 41 + agent/kick_logs_test.go | 545 +++ agent/kick_observer.go | 48 + agent/kick_observer_test.go | 47 + agent/kick_outcome.go | 249 ++ agent/kick_outcome_test.go | 263 ++ agent/kick_refusal_echo_test.go | 30 + agent/kick_restart_recovery_test.go | 195 + agent/kick_transcript.go | 356 ++ agent/kick_transcript_claude.go | 105 + agent/kick_transcript_copilot.go | 108 + agent/kick_transcript_test.go | 411 ++ agent/kill_floor_guard_test.go | 114 + agent/launch_coverage_test.go | 141 + agent/launch_failure_banner_test.go | 162 + agent/launch_scrub_test.go | 210 + agent/linear_credential_test.go | 132 + agent/login_code.go | 91 + agent/login_code_test.go | 149 + agent/login_expiry_test.go | 41 + agent/login_model_403_test.go | 155 + agent/manager.go | 1804 ++++++++ agent/manager3_coverage_test.go | 536 +++ agent/manager_authz_count_test.go | 340 ++ agent/manager_consent.go | 295 ++ agent/manager_copilot_auth.go | 93 + agent/manager_copilot_transient_test.go | 144 + agent/manager_coverage2_test.go | 1831 +++++++++ agent/manager_coverage_test.go | 563 +++ agent/manager_credentials.go | 302 ++ agent/manager_env.go | 532 +++ agent/manager_env_git_identity_test.go | 138 + agent/manager_extra_test.go | 505 +++ agent/manager_helper_paths_test.go | 338 ++ agent/manager_homes.go | 1010 +++++ agent/manager_inference_home_anchor_test.go | 159 + agent/manager_kick.go | 1532 +++++++ agent/manager_launch.go | 1236 ++++++ agent/manager_modes.go | 398 ++ agent/manager_operator_stop_test.go | 68 + agent/manager_pane_sanitizer_test.go | 75 + agent/manager_pane_signals.go | 917 +++++ agent/manager_pause.go | 402 ++ agent/manager_poll.go | 685 ++++ agent/manager_poll_kick_delivery_test.go | 136 + agent/manager_poll_model_fallback_test.go | 33 + agent/manager_replicas_test.go | 30 + agent/manager_restart.go | 990 +++++ agent/manager_restart_telemetry.go | 86 + agent/manager_routing.go | 456 +++ agent/manager_sandbox.go | 314 ++ agent/manager_sandbox_wiring_test.go | 46 + agent/manager_symlink_test.go | 98 + agent/manager_terminal.go | 90 + agent/manager_test.go | 967 +++++ agent/manager_thrash.go | 122 + agent/manager_tmux.go | 643 +++ agent/manager_tokens.go | 973 +++++ agent/manager_uid_isolation.go | 145 + agent/mint_deadlock_test.go | 86 + agent/mint_token_cache_test.go | 199 + agent/mode.go | 34 + agent/mode_test.go | 196 + agent/not_running_reason_test.go | 131 + agent/oomkill.go | 97 + agent/oomkill_test.go | 66 + agent/pane_classify.go | 606 +++ agent/pane_fixtures_test.go | 158 + agent/pane_rate_limit_test.go | 96 + agent/pane_redaction_test.go | 80 + agent/pause_observer_clear_test.go | 30 + agent/pause_provenance_test.go | 139 + agent/permissions_agylog_test.go | 51 + agent/permissions_bobstate_test.go | 205 + agent/permissions_dedupe_test.go | 306 ++ agent/permissions_fixentry_test.go | 271 ++ agent/permissions_modefile_test.go | 116 + agent/permissions_sharedrepo_test.go | 150 + agent/permissions_symlink_test.go | 163 + agent/permissions_watcher.go | 815 ++++ agent/permissions_watcher_start_test.go | 279 ++ agent/persist_pause_deadlock_test.go | 115 + agent/pi_backend_test.go | 61 + agent/pluk_dirs.go | 129 + agent/pluk_dirs_branches_test.go | 158 + agent/pluk_dirs_test.go | 42 + agent/pluk_publisher_test.go | 205 + agent/poll_coverage_test.go | 100 + agent/pr_session_resume.go | 160 + agent/pr_session_resume_test.go | 183 + agent/proc_coverage_test.go | 154 + agent/process_leak_guard_test.go | 235 ++ agent/provider_error_helpers_test.go | 102 + agent/provider_error_state_test.go | 267 ++ agent/provider_error_test.go | 168 + agent/provider_status.go | 33 + agent/quota_exhaustion_test.go | 38 + agent/rate_limit_relaunch_test.go | 56 + agent/reaper_test.go | 135 + agent/record_prompt_test.go | 143 + agent/relaunch_mint_test.go | 209 + agent/relaunch_observer_test.go | 31 + agent/relaunch_race_guard_test.go | 255 ++ agent/repo_pause_test.go | 114 + agent/restart_storm_breaker.go | 175 + agent/restart_telemetry_test.go | 151 + agent/ringbuffer.go | 13 + agent/routing_coverage_test.go | 649 +++ agent/runtime_uid_isolation_marker_test.go | 146 + agent/sandbox_executor.go | 558 +++ agent/sandbox_executor_baseref_test.go | 167 + agent/sandbox_executor_paths_test.go | 497 +++ agent/sandbox_executor_test.go | 458 +++ agent/sandbox_job_runtime_test.go | 179 + agent/seams_coverage_test.go | 258 ++ agent/sendkick_coverage_test.go | 49 + agent/session_liveness.go | 101 + agent/session_liveness_test.go | 69 + agent/session_missing_test.go | 267 ++ agent/shared_credential_test.go | 384 ++ agent/shared_dev_uid_test.go | 55 + agent/stale_launch_cmd_test.go | 77 + agent/stall_selfreport_test.go | 53 + agent/start_failure.go | 356 ++ agent/start_failure_test.go | 383 ++ agent/start_launch_guard_test.go | 87 + agent/status_fast_test.go | 122 + agent/stuck_login_diagnosis.go | 240 ++ agent/stuck_login_diagnosis_test.go | 536 +++ agent/subagent_models.go | 88 + agent/subagent_models_test.go | 119 + agent/terminal.go | 121 + agent/terminal_seams_test.go | 17 + agent/terminal_send_literal_test.go | 68 + agent/terminal_tmux_hermetic_test.go | 204 + .../kick_transcript/claude_session.golden | 22 + .../kick_transcript/claude_session.jsonl | 7 + .../kick_transcript/copilot_events.golden | 25 + .../kick_transcript/copilot_events.jsonl | 8 + agent/thrash_coverage_test.go | 126 + agent/thrash_poller_wiring_test.go | 121 + agent/tmux/tmux.go | 48 + agent/tmux/tmux_test.go | 80 + agent/tmux_coverage_test.go | 702 ++++ agent/tmux_false_missing_test.go | 122 + agent/tmux_history_limit_test.go | 192 + agent/tmux_inherited_env_test.go | 229 ++ agent/tmux_lifecycle_hermetic_test.go | 123 + agent/tmux_scrollback_legibility_test.go | 194 + agent/token_refresh_test.go | 170 + agent/token_restart_boot_pane_test.go | 312 ++ agent/tracing.go | 40 + agent/tracing_test.go | 43 + agent/trajectory_idle_test.go | 34 + agent/trajectory_view.go | 65 + agent/transient_api_error_nudge_test.go | 363 ++ agent/tui_menu.go | 92 + agent/tui_menu_test.go | 152 + agent/turn_loss.go | 145 + agent/turn_loss_test.go | 231 ++ agent/uid_isolation_migration_test.go | 83 + agent/uidmap.go | 192 + agent/uidmap_internal_test.go | 74 + agent/uidmap_persist_test.go | 168 + agent/uidmap_stable_test.go | 59 + agent/uidmap_test.go | 137 + agent/watchdog_fleet.go | 410 ++ agent/watchdog_fleet_test.go | 688 ++++ agent/watsonx_backend_test.go | 135 + agent/write_as_user_test.go | 181 + agentmode.go | 73 + agentmode/agentmode.go | 158 + agentmode/agentmode_test.go | 130 + agentmode/claim_comment_test.go | 56 + agentmode/reviewer_tier_test.go | 64 + agentmode_test.go | 68 + agentparse/agentparse.go | 362 ++ agentparse/agentparse_test.go | 401 ++ agentsmd/agentsmd.go | 634 +++ agentsmd/agentsmd_test.go | 358 ++ agentsmd/injection_cap_test.go | 196 + api/api.go | 218 + api/api_test.go | 131 + api/client.go | 293 ++ api/client_test.go | 440 ++ api/wire.go | 185 + audit.go | 81 + backend_auth.go | 68 + canary.go | 13 + claude/oauth.go | 293 ++ claude/oauth_test.go | 432 ++ claude/oauth_write_error_branches_test.go | 59 + claude/usable_token_test.go | 134 + cli/cli.go | 290 ++ cli/cli_test.go | 695 ++++ cli/commands.go | 383 ++ cli/control.go | 140 + cli/control_test.go | 255 ++ cli/definitions.go | 53 + cli/loader_test.go | 113 + cli/remote.go | 267 ++ cli/remote_test.go | 409 ++ cli/runtime.go | 27 + cli/serve.go | 67 + cli/serve_test.go | 158 + conditions.go | 35 + conditions_test.go | 40 + config/agent_config.go | 701 ++++ config/agent_repos.go | 33 + config/agent_runtime.go | 1359 +++++++ config/backends.go | 49 + config/bob_test.go | 164 + config/doc.go | 19 + config/gateways.go | 99 + config/gateways_test.go | 61 + config/kick_limits.go | 82 + config/kick_log_source.go | 79 + config/leaf_test.go | 620 +++ config/litellm_route.go | 116 + config/litellm_route_test.go | 66 + config/origin_labels.go | 362 ++ config/origin_labels_test.go | 141 + config/providers.go | 21 + config/proxy_inject.go | 40 + config/reasoning_effort_test.go | 70 + config/repo_pause.go | 21 + config/review_heads.go | 86 + config/rotation_config_test.go | 96 + config/sandbox_config_test.go | 32 + config/secret_path_test.go | 133 + config/serve.go | 93 + config/serve_test.go | 67 + config/tool_presets.go | 110 + config/tool_presets_test.go | 238 ++ config/triggers_config.go | 43 + config/turn_config.go | 44 + config/watchdog.go | 75 + copilot_models.go | 126 + copilot_models_test.go | 113 + cruxtest/runtime.go | 75 + cruxtest/runtime_test.go | 62 + effects/effects.go | 326 ++ effects/effects_test.go | 210 + effects/execute_reconciling_test.go | 183 + effects/reconcile_test.go | 103 + gitidentity/gitidentity.go | 61 + gitidentity/gitidentity_test.go | 82 + go.mod | 32 + go.sum | 53 + host.go | 58 + host_test.go | 50 + inference/gateway_resolution_test.go | 97 + inference/gateway_watsonx_auth_test.go | 130 + inference/inference.go | 186 + inference/litellm_route_resolution_test.go | 295 ++ inference/litellm_supervision_test.go | 161 + inference/port_collision_7238_test.go | 63 + inferencehealth/inferencehealth.go | 227 ++ inferencehealth/inferencehealth_test.go | 276 ++ internal/testutil/testutil.go | 115 + internal/testutil/testutil_test.go | 79 + issueshape/issueshape.go | 100 + issueshape/issueshape_test.go | 104 + jev/env.go | 16 + jev/jev_test.go | 33 + jev/skill.go | 44 + jev/skill/SKILL.md | 77 + jsonextract/jsonextract.go | 23 + jsonextract/jsonextract_test.go | 30 + kick.go | 97 + kick_test.go | 35 + kubejob/kubejob.go | 656 +++ kubejob/kubejob_test.go | 594 +++ logscrub/handler.go | 136 + logscrub/handler_coverage_test.go | 108 + logscrub/handler_test.go | 94 + logscrub/pane.go | 46 + logscrub/pane_test.go | 34 + logscrub/redaction.go | 63 + logscrub/redaction_test.go | 77 + logscrub/scrub_test.go | 169 + observation.go | 76 + observation_login_test.go | 75 + observation_test.go | 37 + openrouter/openrouter.go | 405 ++ openrouter/openrouter_http_test.go | 313 ++ openrouter/openrouter_test.go | 164 + outputschema/outputschema.go | 440 ++ outputschema/outputschema_test.go | 365 ++ outputschema/testdata/stage_receipt_v1.json | 39 + paneclass/paneclass.go | 33 + paneclass/paneclass_test.go | 31 + pause.go | 32 + process.go | 108 + process_test.go | 41 + process_types.go | 121 + project.go | 139 + project_test.go | 140 + provenance.go | 15 + provider_status.go | 246 ++ provider_status_test.go | 162 + proxy/agent_github_budget.go | 249 ++ proxy/agent_github_budget_test.go | 150 + proxy/agent_identification_test.go | 175 + proxy/agent_repo_scope_test.go | 251 ++ proxy/anthropic_translate.go | 710 ++++ proxy/anthropic_translate_test.go | 746 ++++ proxy/auth_inject_copilot_test.go | 104 + proxy/auth_inject_test.go | 385 ++ proxy/autonomy_enforcement_test.go | 33 + proxy/autonomy_levels.go | 66 + proxy/canary_test.go | 170 + proxy/clienthello_record_test.go | 97 + proxy/close_wait_leak_test.go | 295 ++ proxy/converse_capability_test.go | 311 ++ proxy/converse_review_parity_test.go | 47 + proxy/copilot_anthropic_usage.go | 220 + proxy/copilot_anthropic_usage_test.go | 254 ++ proxy/copilot_sniff_test.go | 819 ++++ proxy/copilot_sniff_write_error_test.go | 42 + proxy/copilot_usage.go | 175 + proxy/copilot_usage_test.go | 197 + proxy/denial_message.go | 43 + proxy/denial_message_test.go | 102 + proxy/entitlement.go | 375 ++ proxy/entitlement_default_model_test.go | 113 + proxy/entitlement_test.go | 370 ++ proxy/gateway_health_signal_test.go | 131 + proxy/gateway_path_canary_test.go | 428 ++ proxy/github_proxy.go | 3620 +++++++++++++++++ proxy/github_proxy_coverage_test.go | 474 +++ proxy/github_proxy_util_test.go | 274 ++ proxy/host_seams.go | 91 + proxy/host_seams_test.go | 78 + proxy/identify_agent_by_uid_test.go | 61 + proxy/inference_auth.go | 148 + proxy/inference_auth_signal_test.go | 166 + proxy/inference_auth_test.go | 48 + proxy/inference_budget.go | 220 + proxy/inference_budget_test.go | 266 ++ proxy/inference_error_message.go | 47 + proxy/inference_error_message_test.go | 26 + proxy/inference_forward.go | 99 + proxy/inference_forward_url_test.go | 25 + proxy/inference_integration_test.go | 113 + proxy/inference_last_error.go | 103 + proxy/inference_last_error_test.go | 75 + proxy/inference_path.go | 117 + proxy/inference_path_test.go | 233 ++ proxy/inference_route.go | 445 ++ proxy/inference_route_test.go | 258 ++ proxy/inference_translator_start_test.go | 47 + proxy/issue_shape_limits_test.go | 110 + proxy/issue_shape_test.go | 148 + proxy/linear_inject_test.go | 168 + proxy/linear_rules.go | 555 +++ proxy/linear_rules_parser_edges_test.go | 199 + proxy/linear_rules_test.go | 551 +++ proxy/loopback_host_test.go | 24 + proxy/origin_label_inject_test.go | 112 + proxy/plain_forward_guard.go | 171 + proxy/plain_forward_guard_test.go | 283 ++ proxy/procnet.go | 85 + proxy/procnet_test.go | 122 + proxy/proxy_coverage2_test.go | 932 +++++ proxy/proxy_coverage_test.go | 418 ++ proxy/proxy_deep2_test.go | 753 ++++ proxy/proxy_deep3_test.go | 429 ++ proxy/proxy_deep4_test.go | 142 + proxy/proxy_deep5_test.go | 649 +++ proxy/proxy_deep6_test.go | 293 ++ proxy/proxy_deep7_test.go | 175 + proxy/proxy_deep8_test.go | 444 ++ proxy/proxy_deep_test.go | 1571 +++++++ proxy/proxy_ephemeral_plain_error_test.go | 116 + proxy/proxy_extra_test.go | 318 ++ proxy/proxy_http_test.go | 262 ++ proxy/proxy_test.go | 185 + proxy/proxy_threshold_test.go | 64 + proxy/proxy_tls_test.go | 247 ++ proxy/proxy_transparent_attribution_test.go | 420 ++ proxy/proxy_transparent_tls_repro_test.go | 187 + proxy/repo_pause_test.go | 329 ++ proxy/rules.go | 769 ++++ proxy/rules_test.go | 214 + proxy/somark_linux.go | 20 + proxy/somark_other.go | 11 + proxy/somark_test.go | 87 + proxy/stream_write_stall_test.go | 141 + proxy/tunnel_drain_test.go | 139 + proxy/upstream_timeout_test.go | 141 + proxy/write_counters.go | 74 + proxy/write_counters_store.go | 287 ++ proxy/write_counters_store_test.go | 160 + proxy/write_counters_test.go | 29 + proxy/write_leak_gate_test.go | 97 + proxy/write_surface_enforce.go | 129 + proxy/write_surface_enforce_test.go | 303 ++ pushbroker/pushbroker.go | 911 +++++ pushbroker/pushbroker_coverage_test.go | 444 ++ pushbroker/pushbroker_test.go | 1200 ++++++ ringbuffer.go | 60 + ringbuffer_test.go | 116 + runtime.go | 223 + runtime_test.go | 79 + sandbox/credential_name_test.go | 16 + sandbox/sandbox.go | 159 + sandbox/sandbox_coverage_test.go | 194 + sandbox/sandbox_launcher_test.go | 133 + sandbox/sandbox_test.go | 108 + sandbox/worktree.go | 137 + sandbox/worktree_test.go | 255 ++ serve/serve.go | 792 ++++ serve/serve_test.go | 1096 +++++ skillreg/agentrepos.go | 123 + skillreg/agentrepos_test.go | 112 + skillreg/agentspec.go | 181 + skillreg/agentspec_test.go | 110 + skillreg/skillreg.go | 463 +++ skillreg/skillreg_test.go | 386 ++ toolapprove/bulk_parity_test.go | 193 + toolapprove/classify.go | 145 + toolapprove/coverage_test.go | 180 + toolapprove/decide.go | 244 ++ toolapprove/desk.go | 462 +++ toolapprove/desk_ceiling_test.go | 173 + toolapprove/inbox.go | 487 +++ toolapprove/inbox_l6_test.go | 188 + toolapprove/inbox_test.go | 269 ++ toolapprove/parity.go | 126 + toolapprove/parity_test.go | 172 + toolapprove/rules.go | 287 ++ toolapprove/rules_test.go | 162 + toolapprove/security.go | 233 ++ toolapprove/text_scanner_test.go | 71 + toolapprove/toolapprove_test.go | 448 ++ toolapprove/types.go | 195 + toolapprove/wire.go | 50 + toolapprove/wire_test.go | 281 ++ trajectory/lane.go | 344 ++ trajectory/lane_test.go | 235 ++ trajectory/skip_test.go | 166 + trajectory/trajectory.go | 242 ++ trajectory/trajectory_test.go | 208 + watchdog/classify.go | 271 ++ watchdog/classify_copilot_login_test.go | 23 + watchdog/classify_login_grace_test.go | 116 + watchdog/classify_test.go | 202 + watchdog/conditions.go | 78 + watchdog/conditions_test.go | 48 + watchdog/reconciler.go | 966 +++++ watchdog/reconciler_options_test.go | 34 + watchdog/reconciler_test.go | 1715 ++++++++ watchdog/refreshable_credential_test.go | 190 + watchdog/rotation_probe.go | 64 + watchdog/rotation_probe_test.go | 45 + watchdog/settings.go | 265 ++ watchdog/settings_test.go | 191 + watchdog_conditions.go | 43 + watsonx/endpoint.go | 34 + watsonx/endpoint_test.go | 47 + watsonx/iam.go | 205 + watsonx/iam_test.go | 244 ++ 711 files changed, 136013 insertions(+) create mode 100644 .dockerignore create mode 100755 .github/scripts/smoke.sh create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/sync-guard.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .goreleaser.yaml create mode 100644 .hive-sync create mode 100644 Dockerfile create mode 100644 Dockerfile.goreleaser create mode 100644 activity.go create mode 100755 agent/.hive-agent-stubs-1969186452/bob create mode 100755 agent/.hive-agent-stubs-1969186452/claude create mode 100755 agent/.hive-agent-stubs-1969186452/copilot create mode 100755 agent/.hive-agent-stubs-1969186452/gemini create mode 100755 agent/.hive-agent-stubs-1969186452/goose create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-bootstrap-cxa.txt create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-a create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-agent create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-bad create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-covdiag create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-covhang create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-cxa create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-env-test create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-guide create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-inf create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-inference-agent create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-kdfires create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-nomodel create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-quality create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-reviewer create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-scanner create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-schema create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-sec-check create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-sub create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-test create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-test-agent create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-tester create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-caps-worker create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-a create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-agent create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-bad create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-covdiag create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-covhang create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-cxa create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-env-test create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-guide create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-inf create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-inference-agent create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-kdfires create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-nomodel create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-quality create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-reviewer create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-scanner create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-schema create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-sec-check create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-sub create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-test create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-test-agent create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-tester create mode 100644 agent/.hive-agent-stubs-1969186452/perm/.hive-mode-worker create mode 100644 agent/.hive-agent-stubs-1969186452/pluk/logs/hive-a-never-created.jsonl create mode 100644 agent/.hive-agent-stubs-1969186452/pluk/logs/hive-a.jsonl create mode 100644 agent/.hive-agent-stubs-1969186452/pluk/logs/hive-bad.jsonl create mode 100644 agent/.hive-agent-stubs-1969186452/pluk/logs/hive-covdiag.jsonl create mode 100644 agent/.hive-agent-stubs-1969186452/pluk/logs/hive-covhang.jsonl create mode 100644 agent/.hive-agent-stubs-1969186452/pluk/logs/hive-cxa.jsonl create mode 100644 agent/.hive-agent-stubs-1969186452/pluk/logs/hive-kdfires.jsonl create mode 100644 agent/.hive-agent-stubs-1969186452/pluk/logs/hive-reviewer.jsonl create mode 100644 agent/.hive-agent-stubs-1969186452/pluk/logs/hive-scanner-kicklog-test.jsonl create mode 100644 agent/.hive-agent-stubs-1969186452/pluk/logs/hive-scanner.jsonl create mode 100644 agent/.hive-agent-stubs-1969186452/pluk/logs/hive-worker.jsonl create mode 100755 agent/.hive-agent-stubs-6766624/bob create mode 100755 agent/.hive-agent-stubs-6766624/claude create mode 100755 agent/.hive-agent-stubs-6766624/copilot create mode 100755 agent/.hive-agent-stubs-6766624/gemini create mode 100755 agent/.hive-agent-stubs-6766624/goose create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-bootstrap-cxa.txt create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-a create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-agent create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-bad create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-covdiag create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-cxa create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-env-test create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-guide create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-inf create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-inference-agent create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-kdfires create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-nomodel create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-quality create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-reviewer create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-scanner create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-schema create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-sec-check create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-sub create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-test create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-test-agent create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-tester create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-caps-worker create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-a create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-agent create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-bad create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-covdiag create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-cxa create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-env-test create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-guide create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-inf create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-inference-agent create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-kdfires create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-nomodel create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-quality create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-reviewer create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-scanner create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-schema create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-sec-check create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-sub create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-test create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-test-agent create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-tester create mode 100644 agent/.hive-agent-stubs-6766624/perm/.hive-mode-worker create mode 100644 agent/.hive-agent-stubs-6766624/pluk/logs/hive-a-never-created.jsonl create mode 100644 agent/.hive-agent-stubs-6766624/pluk/logs/hive-a.jsonl create mode 100644 agent/.hive-agent-stubs-6766624/pluk/logs/hive-bad.jsonl create mode 100644 agent/.hive-agent-stubs-6766624/pluk/logs/hive-covdiag.jsonl create mode 100644 agent/.hive-agent-stubs-6766624/pluk/logs/hive-cxa.jsonl create mode 100644 agent/.hive-agent-stubs-6766624/pluk/logs/hive-kdfires.jsonl create mode 100644 agent/.hive-agent-stubs-6766624/pluk/logs/hive-reviewer.jsonl create mode 100644 agent/.hive-agent-stubs-6766624/pluk/logs/hive-scanner-kicklog-test.jsonl create mode 100644 agent/.hive-agent-stubs-6766624/pluk/logs/hive-scanner.jsonl create mode 100644 agent/.hive-agent-stubs-6766624/pluk/logs/hive-worker.jsonl create mode 100644 agent/acmm_gate.go create mode 100644 agent/agent_bootstrap_test.go create mode 100644 agent/agent_capabilities_test.go create mode 100644 agent/agent_repo_scope_test.go create mode 100644 agent/agent_state_file_test.go create mode 100644 agent/agent_unit_test.go create mode 100644 agent/agent_working_test.go create mode 100644 agent/agentspecwire.go create mode 100644 agent/agentspecwire_test.go create mode 100644 agent/agy_launch_test.go create mode 100644 agent/agy_restart_gate_test.go create mode 100644 agent/agy_turn.go create mode 100644 agent/agy_turn_test.go create mode 100644 agent/antigravity_credential_test.go create mode 100644 agent/antigravity_onboarding_test.go create mode 100644 agent/antigravity_perm_guard_test.go create mode 100644 agent/audit.go create mode 100644 agent/audit_test.go create mode 100644 agent/authorize_issue_open_test.go create mode 100644 agent/authorize_push_branch_test.go create mode 100644 agent/authorize_review_request_test.go create mode 100644 agent/authprobe.go create mode 100644 agent/authprobe_credential_gate_test.go create mode 100644 agent/authprobe_test.go create mode 100644 agent/backend_auth.go create mode 100644 agent/backend_auth_test.go create mode 100644 agent/backend_coverage_test.go create mode 100644 agent/backend_launch_cmd_test.go create mode 100644 agent/backend_resume.go create mode 100644 agent/backend_resume_test.go create mode 100644 agent/blocking_prompt_test.go create mode 100644 agent/bob_display_name_test.go create mode 100644 agent/bob_key_relaunch_loop_test.go create mode 100644 agent/bob_key_relaunch_test.go create mode 100644 agent/bob_launch_shell_test.go create mode 100644 agent/bob_settings.go create mode 100644 agent/bob_settings_test.go create mode 100644 agent/bob_startup_kick_test.go create mode 100644 agent/bob_test.go create mode 100644 agent/branches_coverage_test.go create mode 100644 agent/busy_visibility.go create mode 100644 agent/busy_visibility_test.go create mode 100644 agent/capabilities.go create mode 100644 agent/caveman_install_test.go create mode 100644 agent/caveman_pin_test.go create mode 100644 agent/ci_poll_pr_view_test.go create mode 100644 agent/ci_poll_prohibition_test.go create mode 100644 agent/ci_poll_sweep_test.go create mode 100644 agent/ci_poll_wall_clock_test.go create mode 100644 agent/claude_credential_paths_test.go create mode 100644 agent/claude_launch_test.go create mode 100644 agent/claude_oauth_env_test.go create mode 100644 agent/claude_remote_control.go create mode 100644 agent/claude_remote_control_test.go create mode 100644 agent/claude_session_adopt.go create mode 100644 agent/claude_session_adopt_fallback_test.go create mode 100644 agent/claude_session_adopt_test.go create mode 100644 agent/claude_session_json_helpers_test.go create mode 100644 agent/claude_session_state.go create mode 100644 agent/codex_home_nfs_safety_test.go create mode 100644 agent/codex_kick_regression_test.go create mode 100644 agent/codex_launch_test.go create mode 100644 agent/codex_readiness_test.go create mode 100644 agent/conditions.go create mode 100644 agent/conditions_test.go create mode 100644 agent/config_has_tokens_test.go create mode 100644 agent/consent_wedge.go create mode 100644 agent/consent_wedge_test.go create mode 100644 agent/converse_capability_test.go create mode 100644 agent/copilot_auth_error_test.go create mode 100644 agent/copilot_cache_janitor.go create mode 100644 agent/copilot_cache_janitor_test.go create mode 100644 agent/copilot_models.go create mode 100644 agent/copilot_models_test.go create mode 100644 agent/copilot_question_form.go create mode 100644 agent/copilot_question_form_test.go create mode 100644 agent/copilot_session_refresh_test.go create mode 100644 agent/copilot_session_startdelay_test.go create mode 100644 agent/copilot_token_login_test.go create mode 100644 agent/copilot_token_promote_test.go create mode 100644 agent/copilot_token_propagate_test.go create mode 100644 agent/copilot_token_store.go create mode 100644 agent/coverage_boost_test.go create mode 100644 agent/crash_coverage_test.go create mode 100644 agent/credential_watchdog_test.go create mode 100644 agent/credentials_watchdog.go create mode 100644 agent/crux_runtime.go create mode 100644 agent/crux_runtime_test.go create mode 100644 agent/dashboard_token_isolation_test.go create mode 100644 agent/dedup_bench_test.go create mode 100644 agent/diag_coverage_test.go create mode 100644 agent/dismiss_coverage_test.go create mode 100644 agent/dismiss_prompts_fake_test.go create mode 100644 agent/enters_prompt_guard_test.go create mode 100644 agent/envpairs_coverage_test.go create mode 100644 agent/explain_mode_test.go create mode 100644 agent/fatal_network_producing_test.go create mode 100644 agent/fleet_breaker_test.go create mode 100644 agent/gitremotes_coverage_test.go create mode 100644 agent/helpers_coverage_test.go create mode 100644 agent/host_state_deny_test.go create mode 100644 agent/interactive_home.go create mode 100644 agent/interactive_home_errors_test.go create mode 100644 agent/interactive_home_gitconfig_test.go create mode 100644 agent/interactive_home_test.go create mode 100644 agent/invocation_metadata_test.go create mode 100644 agent/jev_install.go create mode 100644 agent/jev_install_test.go create mode 100644 agent/kick_async.go create mode 100644 agent/kick_async_deliver_test.go create mode 100644 agent/kick_async_test.go create mode 100644 agent/kick_cancel.go create mode 100644 agent/kick_cancel_test.go create mode 100644 agent/kick_chunk_pause_test.go create mode 100644 agent/kick_delivery_failure_test.go create mode 100644 agent/kick_log_alt_screen_test.go create mode 100644 agent/kick_logs.go create mode 100644 agent/kick_logs_dir_test.go create mode 100644 agent/kick_logs_test.go create mode 100644 agent/kick_observer.go create mode 100644 agent/kick_observer_test.go create mode 100644 agent/kick_outcome.go create mode 100644 agent/kick_outcome_test.go create mode 100644 agent/kick_refusal_echo_test.go create mode 100644 agent/kick_restart_recovery_test.go create mode 100644 agent/kick_transcript.go create mode 100644 agent/kick_transcript_claude.go create mode 100644 agent/kick_transcript_copilot.go create mode 100644 agent/kick_transcript_test.go create mode 100644 agent/kill_floor_guard_test.go create mode 100644 agent/launch_coverage_test.go create mode 100644 agent/launch_failure_banner_test.go create mode 100644 agent/launch_scrub_test.go create mode 100644 agent/linear_credential_test.go create mode 100644 agent/login_code.go create mode 100644 agent/login_code_test.go create mode 100644 agent/login_expiry_test.go create mode 100644 agent/login_model_403_test.go create mode 100644 agent/manager.go create mode 100644 agent/manager3_coverage_test.go create mode 100644 agent/manager_authz_count_test.go create mode 100644 agent/manager_consent.go create mode 100644 agent/manager_copilot_auth.go create mode 100644 agent/manager_copilot_transient_test.go create mode 100644 agent/manager_coverage2_test.go create mode 100644 agent/manager_coverage_test.go create mode 100644 agent/manager_credentials.go create mode 100644 agent/manager_env.go create mode 100644 agent/manager_env_git_identity_test.go create mode 100644 agent/manager_extra_test.go create mode 100644 agent/manager_helper_paths_test.go create mode 100644 agent/manager_homes.go create mode 100644 agent/manager_inference_home_anchor_test.go create mode 100644 agent/manager_kick.go create mode 100644 agent/manager_launch.go create mode 100644 agent/manager_modes.go create mode 100644 agent/manager_operator_stop_test.go create mode 100644 agent/manager_pane_sanitizer_test.go create mode 100644 agent/manager_pane_signals.go create mode 100644 agent/manager_pause.go create mode 100644 agent/manager_poll.go create mode 100644 agent/manager_poll_kick_delivery_test.go create mode 100644 agent/manager_poll_model_fallback_test.go create mode 100644 agent/manager_replicas_test.go create mode 100644 agent/manager_restart.go create mode 100644 agent/manager_restart_telemetry.go create mode 100644 agent/manager_routing.go create mode 100644 agent/manager_sandbox.go create mode 100644 agent/manager_sandbox_wiring_test.go create mode 100644 agent/manager_symlink_test.go create mode 100644 agent/manager_terminal.go create mode 100644 agent/manager_test.go create mode 100644 agent/manager_thrash.go create mode 100644 agent/manager_tmux.go create mode 100644 agent/manager_tokens.go create mode 100644 agent/manager_uid_isolation.go create mode 100644 agent/mint_deadlock_test.go create mode 100644 agent/mint_token_cache_test.go create mode 100644 agent/mode.go create mode 100644 agent/mode_test.go create mode 100644 agent/not_running_reason_test.go create mode 100644 agent/oomkill.go create mode 100644 agent/oomkill_test.go create mode 100644 agent/pane_classify.go create mode 100644 agent/pane_fixtures_test.go create mode 100644 agent/pane_rate_limit_test.go create mode 100644 agent/pane_redaction_test.go create mode 100644 agent/pause_observer_clear_test.go create mode 100644 agent/pause_provenance_test.go create mode 100644 agent/permissions_agylog_test.go create mode 100644 agent/permissions_bobstate_test.go create mode 100644 agent/permissions_dedupe_test.go create mode 100644 agent/permissions_fixentry_test.go create mode 100644 agent/permissions_modefile_test.go create mode 100644 agent/permissions_sharedrepo_test.go create mode 100644 agent/permissions_symlink_test.go create mode 100644 agent/permissions_watcher.go create mode 100644 agent/permissions_watcher_start_test.go create mode 100644 agent/persist_pause_deadlock_test.go create mode 100644 agent/pi_backend_test.go create mode 100644 agent/pluk_dirs.go create mode 100644 agent/pluk_dirs_branches_test.go create mode 100644 agent/pluk_dirs_test.go create mode 100644 agent/pluk_publisher_test.go create mode 100644 agent/poll_coverage_test.go create mode 100644 agent/pr_session_resume.go create mode 100644 agent/pr_session_resume_test.go create mode 100644 agent/proc_coverage_test.go create mode 100644 agent/process_leak_guard_test.go create mode 100644 agent/provider_error_helpers_test.go create mode 100644 agent/provider_error_state_test.go create mode 100644 agent/provider_error_test.go create mode 100644 agent/provider_status.go create mode 100644 agent/quota_exhaustion_test.go create mode 100644 agent/rate_limit_relaunch_test.go create mode 100644 agent/reaper_test.go create mode 100644 agent/record_prompt_test.go create mode 100644 agent/relaunch_mint_test.go create mode 100644 agent/relaunch_observer_test.go create mode 100644 agent/relaunch_race_guard_test.go create mode 100644 agent/repo_pause_test.go create mode 100644 agent/restart_storm_breaker.go create mode 100644 agent/restart_telemetry_test.go create mode 100644 agent/ringbuffer.go create mode 100644 agent/routing_coverage_test.go create mode 100644 agent/runtime_uid_isolation_marker_test.go create mode 100644 agent/sandbox_executor.go create mode 100644 agent/sandbox_executor_baseref_test.go create mode 100644 agent/sandbox_executor_paths_test.go create mode 100644 agent/sandbox_executor_test.go create mode 100644 agent/sandbox_job_runtime_test.go create mode 100644 agent/seams_coverage_test.go create mode 100644 agent/sendkick_coverage_test.go create mode 100644 agent/session_liveness.go create mode 100644 agent/session_liveness_test.go create mode 100644 agent/session_missing_test.go create mode 100644 agent/shared_credential_test.go create mode 100644 agent/shared_dev_uid_test.go create mode 100644 agent/stale_launch_cmd_test.go create mode 100644 agent/stall_selfreport_test.go create mode 100644 agent/start_failure.go create mode 100644 agent/start_failure_test.go create mode 100644 agent/start_launch_guard_test.go create mode 100644 agent/status_fast_test.go create mode 100644 agent/stuck_login_diagnosis.go create mode 100644 agent/stuck_login_diagnosis_test.go create mode 100644 agent/subagent_models.go create mode 100644 agent/subagent_models_test.go create mode 100644 agent/terminal.go create mode 100644 agent/terminal_seams_test.go create mode 100644 agent/terminal_send_literal_test.go create mode 100644 agent/terminal_tmux_hermetic_test.go create mode 100644 agent/testdata/kick_transcript/claude_session.golden create mode 100644 agent/testdata/kick_transcript/claude_session.jsonl create mode 100644 agent/testdata/kick_transcript/copilot_events.golden create mode 100644 agent/testdata/kick_transcript/copilot_events.jsonl create mode 100644 agent/thrash_coverage_test.go create mode 100644 agent/thrash_poller_wiring_test.go create mode 100644 agent/tmux/tmux.go create mode 100644 agent/tmux/tmux_test.go create mode 100644 agent/tmux_coverage_test.go create mode 100644 agent/tmux_false_missing_test.go create mode 100644 agent/tmux_history_limit_test.go create mode 100644 agent/tmux_inherited_env_test.go create mode 100644 agent/tmux_lifecycle_hermetic_test.go create mode 100644 agent/tmux_scrollback_legibility_test.go create mode 100644 agent/token_refresh_test.go create mode 100644 agent/token_restart_boot_pane_test.go create mode 100644 agent/tracing.go create mode 100644 agent/tracing_test.go create mode 100644 agent/trajectory_idle_test.go create mode 100644 agent/trajectory_view.go create mode 100644 agent/transient_api_error_nudge_test.go create mode 100644 agent/tui_menu.go create mode 100644 agent/tui_menu_test.go create mode 100644 agent/turn_loss.go create mode 100644 agent/turn_loss_test.go create mode 100644 agent/uid_isolation_migration_test.go create mode 100644 agent/uidmap.go create mode 100644 agent/uidmap_internal_test.go create mode 100644 agent/uidmap_persist_test.go create mode 100644 agent/uidmap_stable_test.go create mode 100644 agent/uidmap_test.go create mode 100644 agent/watchdog_fleet.go create mode 100644 agent/watchdog_fleet_test.go create mode 100644 agent/watsonx_backend_test.go create mode 100644 agent/write_as_user_test.go create mode 100644 agentmode.go create mode 100644 agentmode/agentmode.go create mode 100644 agentmode/agentmode_test.go create mode 100644 agentmode/claim_comment_test.go create mode 100644 agentmode/reviewer_tier_test.go create mode 100644 agentmode_test.go create mode 100644 agentparse/agentparse.go create mode 100644 agentparse/agentparse_test.go create mode 100644 agentsmd/agentsmd.go create mode 100644 agentsmd/agentsmd_test.go create mode 100644 agentsmd/injection_cap_test.go create mode 100644 api/api.go create mode 100644 api/api_test.go create mode 100644 api/client.go create mode 100644 api/client_test.go create mode 100644 api/wire.go create mode 100644 audit.go create mode 100644 backend_auth.go create mode 100644 canary.go create mode 100644 claude/oauth.go create mode 100644 claude/oauth_test.go create mode 100644 claude/oauth_write_error_branches_test.go create mode 100644 claude/usable_token_test.go create mode 100644 cli/cli.go create mode 100644 cli/cli_test.go create mode 100644 cli/commands.go create mode 100644 cli/control.go create mode 100644 cli/control_test.go create mode 100644 cli/definitions.go create mode 100644 cli/loader_test.go create mode 100644 cli/remote.go create mode 100644 cli/remote_test.go create mode 100644 cli/runtime.go create mode 100644 cli/serve.go create mode 100644 cli/serve_test.go create mode 100644 conditions.go create mode 100644 conditions_test.go create mode 100644 config/agent_config.go create mode 100644 config/agent_repos.go create mode 100644 config/agent_runtime.go create mode 100644 config/backends.go create mode 100644 config/bob_test.go create mode 100644 config/doc.go create mode 100644 config/gateways.go create mode 100644 config/gateways_test.go create mode 100644 config/kick_limits.go create mode 100644 config/kick_log_source.go create mode 100644 config/leaf_test.go create mode 100644 config/litellm_route.go create mode 100644 config/litellm_route_test.go create mode 100644 config/origin_labels.go create mode 100644 config/origin_labels_test.go create mode 100644 config/providers.go create mode 100644 config/proxy_inject.go create mode 100644 config/reasoning_effort_test.go create mode 100644 config/repo_pause.go create mode 100644 config/review_heads.go create mode 100644 config/rotation_config_test.go create mode 100644 config/sandbox_config_test.go create mode 100644 config/secret_path_test.go create mode 100644 config/serve.go create mode 100644 config/serve_test.go create mode 100644 config/tool_presets.go create mode 100644 config/tool_presets_test.go create mode 100644 config/triggers_config.go create mode 100644 config/turn_config.go create mode 100644 config/watchdog.go create mode 100644 copilot_models.go create mode 100644 copilot_models_test.go create mode 100644 cruxtest/runtime.go create mode 100644 cruxtest/runtime_test.go create mode 100644 effects/effects.go create mode 100644 effects/effects_test.go create mode 100644 effects/execute_reconciling_test.go create mode 100644 effects/reconcile_test.go create mode 100644 gitidentity/gitidentity.go create mode 100644 gitidentity/gitidentity_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 host.go create mode 100644 host_test.go create mode 100644 inference/gateway_resolution_test.go create mode 100644 inference/gateway_watsonx_auth_test.go create mode 100644 inference/inference.go create mode 100644 inference/litellm_route_resolution_test.go create mode 100644 inference/litellm_supervision_test.go create mode 100644 inference/port_collision_7238_test.go create mode 100644 inferencehealth/inferencehealth.go create mode 100644 inferencehealth/inferencehealth_test.go create mode 100644 internal/testutil/testutil.go create mode 100644 internal/testutil/testutil_test.go create mode 100644 issueshape/issueshape.go create mode 100644 issueshape/issueshape_test.go create mode 100644 jev/env.go create mode 100644 jev/jev_test.go create mode 100644 jev/skill.go create mode 100644 jev/skill/SKILL.md create mode 100644 jsonextract/jsonextract.go create mode 100644 jsonextract/jsonextract_test.go create mode 100644 kick.go create mode 100644 kick_test.go create mode 100644 kubejob/kubejob.go create mode 100644 kubejob/kubejob_test.go create mode 100644 logscrub/handler.go create mode 100644 logscrub/handler_coverage_test.go create mode 100644 logscrub/handler_test.go create mode 100644 logscrub/pane.go create mode 100644 logscrub/pane_test.go create mode 100644 logscrub/redaction.go create mode 100644 logscrub/redaction_test.go create mode 100644 logscrub/scrub_test.go create mode 100644 observation.go create mode 100644 observation_login_test.go create mode 100644 observation_test.go create mode 100644 openrouter/openrouter.go create mode 100644 openrouter/openrouter_http_test.go create mode 100644 openrouter/openrouter_test.go create mode 100644 outputschema/outputschema.go create mode 100644 outputschema/outputschema_test.go create mode 100644 outputschema/testdata/stage_receipt_v1.json create mode 100644 paneclass/paneclass.go create mode 100644 paneclass/paneclass_test.go create mode 100644 pause.go create mode 100644 process.go create mode 100644 process_test.go create mode 100644 process_types.go create mode 100644 project.go create mode 100644 project_test.go create mode 100644 provenance.go create mode 100644 provider_status.go create mode 100644 provider_status_test.go create mode 100644 proxy/agent_github_budget.go create mode 100644 proxy/agent_github_budget_test.go create mode 100644 proxy/agent_identification_test.go create mode 100644 proxy/agent_repo_scope_test.go create mode 100644 proxy/anthropic_translate.go create mode 100644 proxy/anthropic_translate_test.go create mode 100644 proxy/auth_inject_copilot_test.go create mode 100644 proxy/auth_inject_test.go create mode 100644 proxy/autonomy_enforcement_test.go create mode 100644 proxy/autonomy_levels.go create mode 100644 proxy/canary_test.go create mode 100644 proxy/clienthello_record_test.go create mode 100644 proxy/close_wait_leak_test.go create mode 100644 proxy/converse_capability_test.go create mode 100644 proxy/converse_review_parity_test.go create mode 100644 proxy/copilot_anthropic_usage.go create mode 100644 proxy/copilot_anthropic_usage_test.go create mode 100644 proxy/copilot_sniff_test.go create mode 100644 proxy/copilot_sniff_write_error_test.go create mode 100644 proxy/copilot_usage.go create mode 100644 proxy/copilot_usage_test.go create mode 100644 proxy/denial_message.go create mode 100644 proxy/denial_message_test.go create mode 100644 proxy/entitlement.go create mode 100644 proxy/entitlement_default_model_test.go create mode 100644 proxy/entitlement_test.go create mode 100644 proxy/gateway_health_signal_test.go create mode 100644 proxy/gateway_path_canary_test.go create mode 100644 proxy/github_proxy.go create mode 100644 proxy/github_proxy_coverage_test.go create mode 100644 proxy/github_proxy_util_test.go create mode 100644 proxy/host_seams.go create mode 100644 proxy/host_seams_test.go create mode 100644 proxy/identify_agent_by_uid_test.go create mode 100644 proxy/inference_auth.go create mode 100644 proxy/inference_auth_signal_test.go create mode 100644 proxy/inference_auth_test.go create mode 100644 proxy/inference_budget.go create mode 100644 proxy/inference_budget_test.go create mode 100644 proxy/inference_error_message.go create mode 100644 proxy/inference_error_message_test.go create mode 100644 proxy/inference_forward.go create mode 100644 proxy/inference_forward_url_test.go create mode 100644 proxy/inference_integration_test.go create mode 100644 proxy/inference_last_error.go create mode 100644 proxy/inference_last_error_test.go create mode 100644 proxy/inference_path.go create mode 100644 proxy/inference_path_test.go create mode 100644 proxy/inference_route.go create mode 100644 proxy/inference_route_test.go create mode 100644 proxy/inference_translator_start_test.go create mode 100644 proxy/issue_shape_limits_test.go create mode 100644 proxy/issue_shape_test.go create mode 100644 proxy/linear_inject_test.go create mode 100644 proxy/linear_rules.go create mode 100644 proxy/linear_rules_parser_edges_test.go create mode 100644 proxy/linear_rules_test.go create mode 100644 proxy/loopback_host_test.go create mode 100644 proxy/origin_label_inject_test.go create mode 100644 proxy/plain_forward_guard.go create mode 100644 proxy/plain_forward_guard_test.go create mode 100644 proxy/procnet.go create mode 100644 proxy/procnet_test.go create mode 100644 proxy/proxy_coverage2_test.go create mode 100644 proxy/proxy_coverage_test.go create mode 100644 proxy/proxy_deep2_test.go create mode 100644 proxy/proxy_deep3_test.go create mode 100644 proxy/proxy_deep4_test.go create mode 100644 proxy/proxy_deep5_test.go create mode 100644 proxy/proxy_deep6_test.go create mode 100644 proxy/proxy_deep7_test.go create mode 100644 proxy/proxy_deep8_test.go create mode 100644 proxy/proxy_deep_test.go create mode 100644 proxy/proxy_ephemeral_plain_error_test.go create mode 100644 proxy/proxy_extra_test.go create mode 100644 proxy/proxy_http_test.go create mode 100644 proxy/proxy_test.go create mode 100644 proxy/proxy_threshold_test.go create mode 100644 proxy/proxy_tls_test.go create mode 100644 proxy/proxy_transparent_attribution_test.go create mode 100644 proxy/proxy_transparent_tls_repro_test.go create mode 100644 proxy/repo_pause_test.go create mode 100644 proxy/rules.go create mode 100644 proxy/rules_test.go create mode 100644 proxy/somark_linux.go create mode 100644 proxy/somark_other.go create mode 100644 proxy/somark_test.go create mode 100644 proxy/stream_write_stall_test.go create mode 100644 proxy/tunnel_drain_test.go create mode 100644 proxy/upstream_timeout_test.go create mode 100644 proxy/write_counters.go create mode 100644 proxy/write_counters_store.go create mode 100644 proxy/write_counters_store_test.go create mode 100644 proxy/write_counters_test.go create mode 100644 proxy/write_leak_gate_test.go create mode 100644 proxy/write_surface_enforce.go create mode 100644 proxy/write_surface_enforce_test.go create mode 100644 pushbroker/pushbroker.go create mode 100644 pushbroker/pushbroker_coverage_test.go create mode 100644 pushbroker/pushbroker_test.go create mode 100644 ringbuffer.go create mode 100644 ringbuffer_test.go create mode 100644 runtime.go create mode 100644 runtime_test.go create mode 100644 sandbox/credential_name_test.go create mode 100644 sandbox/sandbox.go create mode 100644 sandbox/sandbox_coverage_test.go create mode 100644 sandbox/sandbox_launcher_test.go create mode 100644 sandbox/sandbox_test.go create mode 100644 sandbox/worktree.go create mode 100644 sandbox/worktree_test.go create mode 100644 serve/serve.go create mode 100644 serve/serve_test.go create mode 100644 skillreg/agentrepos.go create mode 100644 skillreg/agentrepos_test.go create mode 100644 skillreg/agentspec.go create mode 100644 skillreg/agentspec_test.go create mode 100644 skillreg/skillreg.go create mode 100644 skillreg/skillreg_test.go create mode 100644 toolapprove/bulk_parity_test.go create mode 100644 toolapprove/classify.go create mode 100644 toolapprove/coverage_test.go create mode 100644 toolapprove/decide.go create mode 100644 toolapprove/desk.go create mode 100644 toolapprove/desk_ceiling_test.go create mode 100644 toolapprove/inbox.go create mode 100644 toolapprove/inbox_l6_test.go create mode 100644 toolapprove/inbox_test.go create mode 100644 toolapprove/parity.go create mode 100644 toolapprove/parity_test.go create mode 100644 toolapprove/rules.go create mode 100644 toolapprove/rules_test.go create mode 100644 toolapprove/security.go create mode 100644 toolapprove/text_scanner_test.go create mode 100644 toolapprove/toolapprove_test.go create mode 100644 toolapprove/types.go create mode 100644 toolapprove/wire.go create mode 100644 toolapprove/wire_test.go create mode 100644 trajectory/lane.go create mode 100644 trajectory/lane_test.go create mode 100644 trajectory/skip_test.go create mode 100644 trajectory/trajectory.go create mode 100644 trajectory/trajectory_test.go create mode 100644 watchdog/classify.go create mode 100644 watchdog/classify_copilot_login_test.go create mode 100644 watchdog/classify_login_grace_test.go create mode 100644 watchdog/classify_test.go create mode 100644 watchdog/conditions.go create mode 100644 watchdog/conditions_test.go create mode 100644 watchdog/reconciler.go create mode 100644 watchdog/reconciler_options_test.go create mode 100644 watchdog/reconciler_test.go create mode 100644 watchdog/refreshable_credential_test.go create mode 100644 watchdog/rotation_probe.go create mode 100644 watchdog/rotation_probe_test.go create mode 100644 watchdog/settings.go create mode 100644 watchdog/settings_test.go create mode 100644 watchdog_conditions.go create mode 100644 watsonx/endpoint.go create mode 100644 watsonx/endpoint_test.go create mode 100644 watsonx/iam.go create mode 100644 watsonx/iam_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1b4245f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.github +dist +docs +*.md diff --git a/.github/scripts/smoke.sh b/.github/scripts/smoke.sh new file mode 100755 index 0000000..5cf4592 --- /dev/null +++ b/.github/scripts/smoke.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# smoke.sh — prove a crux binary serves and answers. +# +# .github/scripts/smoke.sh [path-to-crux] +# +# Starts `crux serve` on a private unix socket with an empty runtime, drives +# it with `crux --server ps`, stops it with SIGINT and asserts the +# server exits cleanly. Used by CI on linux and macOS and after each release +# against the installed binary. +set -euo pipefail + +CRUX="${1:-./crux}" +[ -x "$CRUX" ] || { echo "smoke: $CRUX is not executable" >&2; exit 1; } + +work="$(mktemp -d "${TMPDIR:-/tmp}/crux-smoke.XXXXXX")" +trap 'rm -rf "$work"' EXIT +# Unix socket paths are limited to ~104 bytes on macOS; keep it short. +sock="$work/s.sock" +log="$work/serve.log" + +"$CRUX" --help >/dev/null 2>&1 || "$CRUX" --help 2>&1 | head -3 || true +if "$CRUX" --version >/dev/null 2>&1; then + echo "smoke: $("$CRUX" --version 2>&1 | head -1)" +fi + +export HIVE_WORK_DIR="$work/workdir" +mkdir -p "$HIVE_WORK_DIR" +"$CRUX" serve --socket "$sock" >"$log" 2>&1 & +pid=$! + +for _ in $(seq 1 50); do + [ -S "$sock" ] && break + if ! kill -0 "$pid" 2>/dev/null; then + echo "smoke: crux serve exited early:" >&2; cat "$log" >&2; exit 1 + fi + sleep 0.2 +done +[ -S "$sock" ] || { echo "smoke: socket $sock never appeared:" >&2; cat "$log" >&2; exit 1; } +grep -q "serving" "$log" || { echo "smoke: no 'serving' banner:" >&2; cat "$log" >&2; exit 1; } + +out="$("$CRUX" --server "$sock" ps 2>&1)" || { echo "smoke: ps failed:" >&2; echo "$out" >&2; cat "$log" >&2; exit 1; } +echo "smoke: ps ->"; echo "$out" | sed 's/^/ /' + +kill -INT "$pid" +rc=0 +for _ in $(seq 1 50); do + if ! kill -0 "$pid" 2>/dev/null; then break; fi + sleep 0.2 +done +if kill -0 "$pid" 2>/dev/null; then + echo "smoke: serve did not exit within 10s after SIGINT" >&2; kill -9 "$pid"; cat "$log" >&2; exit 1 +fi +wait "$pid" || rc=$? +if [ "$rc" -ne 0 ]; then + echo "smoke: serve exited $rc after SIGINT:" >&2; cat "$log" >&2; exit 1 +fi +grep -q "serve stopped" "$log" || { echo "smoke: no 'serve stopped' line:" >&2; cat "$log" >&2; exit 1; } +echo "smoke: ok ($CRUX serve started, answered ps, exited 0)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b099292 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,146 @@ +name: CI + +# The Go code here is a synced copy of hivecommons/hive src/crux (see +# .hive-sync). This CI proves the published, module-path-rewritten tree +# builds, vets, tests and serves on its own; hive's CI is where the code is +# actually gated. Fix failures in hive, never here. + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: build+vet+test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Module is standalone + run: | + set -euo pipefail + test -f .hive-sync && cat .hive-sync + grep -qx 'module github.com/hivecommons/crux' go.mod + if grep -rn --include='*.go' '"github.com/hivecommons/hive' . ; then + echo "::error::tree still imports the hive module path; the sync rewrite is incomplete" >&2 + exit 1 + fi + test -z "$GOWORK" || { echo "::error::GOWORK must be unset" >&2; exit 1; } + go mod verify + - run: go build ./... + - run: go vet ./... + - name: go test -short -race (all but agent) + # Same flags as hive's v2-tests lanes (-short -race -count=1). The + # tmux-bound tests in hive skip themselves via internal/testutil + # (SkipUnlessRequired) unless HIVE_TEST_REQUIRE_BEHAVIOURAL=1, which is + # deliberately NOT set here: this lane proves the published module, not + # the tmux integration hive already gates. ./agent is its own job below + # because it alone takes ~7 minutes of test time. + run: | + # shellcheck disable=SC2046 + go test -short -race -count=1 -timeout 20m $(go list ./... | grep -v '/agent$') + + test-agent: + name: go test ./agent (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Install tmux + run: sudo apt-get update -q && sudo apt-get install -y -q tmux + - run: go test -short -race -count=1 -timeout 35m ./agent/... + + lint: + name: golangci-lint + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: latest + args: --timeout=10m + + smoke: + name: smoke crux serve (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Has cmd/crux + id: has + run: | + if [ -f cmd/crux/main.go ]; then echo "yes=true" >> "$GITHUB_OUTPUT" + else echo "::warning::cmd/crux not yet synced from hive; smoke skipped"; echo "yes=false" >> "$GITHUB_OUTPUT"; fi + - name: Build crux + if: steps.has.outputs.yes == 'true' + run: go build -trimpath -ldflags "-X main.version=ci-${GITHUB_SHA::12}" -o crux ./cmd/crux + - name: Install tmux + if: steps.has.outputs.yes == 'true' && runner.os == 'Linux' + run: sudo apt-get update -q && sudo apt-get install -y -q tmux + - name: Smoke + if: steps.has.outputs.yes == 'true' + run: .github/scripts/smoke.sh ./crux + + goreleaser-check: + name: goreleaser check + snapshot + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 + with: + version: "~> v2" + args: check + - name: Snapshot build (no publish, no docker, no sign) + if: hashFiles('cmd/crux/main.go') != '' + uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 + with: + version: "~> v2" + args: release --snapshot --clean --skip=publish,docker,sign,sbom diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e89af9d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +name: Release + +# Runs on tags vX.Y.Z, which hive's crux-sync workflow pushes when hive is +# tagged crux/vX.Y.Z (a tag may also be pushed here directly). goreleaser +# builds darwin/linux × amd64/arm64 archives + checksums (cosign keyless +# signed), the multi-arch ghcr.io/hivecommons/crux image, and the GitHub +# release with generated notes. The Homebrew formula lives in +# hivecommons/homebrew-hive (Formula/crux.rb) and is bumped by PR. + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to (re)release, e.g. v0.1.0' + type: string + required: true + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 40 + permissions: + contents: write + packages: write + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + fetch-depth: 0 + persist-credentials: false + - name: Tag sanity + run: | + set -euo pipefail + tag="$(git describe --tags --exact-match HEAD)" + [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+].+)?$ ]] || { echo "::error::HEAD tag '$tag' is not vX.Y.Z" >&2; exit 1; } + test -f cmd/crux/main.go || { echo "::error::cmd/crux is not in this tree; nothing to release" >&2; exit 1; } + echo "Releasing $tag from $(grep '^hive_sha=' .hive-sync || echo 'unknown hive sha')" + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Pre-release smoke + run: | + set -euo pipefail + sudo apt-get update -q && sudo apt-get install -y -q tmux + go build -trimpath -ldflags "-X main.version=$(git describe --tags --exact-match HEAD)" -o crux ./cmd/crux + .github/scripts/smoke.sh ./crux + rm -f crux + - uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Summary + run: | + { + echo "## crux ${GITHUB_REF_NAME}" + echo + echo "- Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}" + echo "- Image: \`ghcr.io/hivecommons/crux:${GITHUB_REF_NAME#v}\`" + echo "- \`go install github.com/hivecommons/crux/cmd/crux@${GITHUB_REF_NAME}\`" + echo "- Hive source: $(grep '^source=' .hive-sync | cut -d= -f2-)" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sync-guard.yml b/.github/workflows/sync-guard.yml new file mode 100644 index 0000000..ed6522a --- /dev/null +++ b/.github/workflows/sync-guard.yml @@ -0,0 +1,63 @@ +name: Sync guard + +# The Go code in this repository is produced by hivecommons/hive's +# crux-sync workflow from src/crux there. A pull request here that edits Go +# code would be overwritten by the next sync, so it is refused with a pointer +# to the source of truth. Scaffolding (.github/, .goreleaser.yaml, Dockerfile, +# README.md, LICENSE) is maintained here and passes. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + +jobs: + guard: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Refuse Go edits from non-sync actors + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + ACTOR: ${{ github.actor }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + run: | + set -euo pipefail + # The sync pushes straight to main (no PR), so any PR here is by + # definition not the sync. Keep an allowlist anyway for a future + # PR-based sync actor. + case "$ACTOR" in + hive-release[bot]|hivecommons-hive[bot]) echo "sync actor $ACTOR; skipping"; exit 0 ;; + esac + files="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}/files" --paginate --jq '.[].filename')" + go_files="$(printf '%s\n' "$files" | grep -E '(\.go|^go\.mod|^go\.sum|^\.hive-sync)$' || true)" + if [ -z "$go_files" ]; then + echo "No Go/module files touched; scaffolding-only change is fine here." + exit 0 + fi + marker="" + # shellcheck disable=SC2016 + list="$(printf '%s\n' "$go_files" | sed 's/^/- `/; s/$/`/')" + body="$marker + ### This repository is a synced copy + + The Go code here is generated from [\`hivecommons/hive\` \`src/crux\`](https://github.com/hivecommons/hive/tree/v5/src/crux) by hive's \`crux-sync\` workflow and would be overwritten by the next sync. Please open this change as a pull request against **hivecommons/hive** (base \`v5\`, under \`src/crux/\`); it will be published here automatically once it merges. + + Files this PR touches that are owned by the sync: + $list + + Only scaffolding is maintained in this repository: \`.github/\`, \`.goreleaser.yaml\`, \`Dockerfile\`, \`README.md\`, \`LICENSE\`." + # Strip the YAML indentation the lines above carry. + body="$(printf '%s\n' "$body" | sed 's/^ //')" + existing="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -1)" + if [ -n "$existing" ]; then + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${existing}" -f body="$body" >/dev/null + else + gh pr comment "$PR" --body "$body" + fi + echo "::error::PR touches sync-owned files ($(printf '%s' "$go_files" | tr '\n' ' ')). Source of truth is hivecommons/hive src/crux." + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37c2628 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/crux +/dist/ +*.test +*.out diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..a31054f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,27 @@ +# Lint config for the published copy of hivecommons/hive src/crux. Mirrors the +# enabled set and staticcheck scope of hive's src/.golangci.yml (minus hive's +# depguard rules, which name root-module packages) so the rewritten tree is +# held to exactly the gate it already passes in hive. Tighten there, not here. +version: "2" + +run: + timeout: 10m + tests: true + +linters: + default: none + enable: + - govet + - misspell + - ineffassign + - unused + - staticcheck + settings: + staticcheck: + checks: ["SA*", "-SA1019"] + exclusions: + generated: lax + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..4b91276 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,115 @@ +# goreleaser config for hivecommons/crux. Runs from .github/workflows/release.yml +# on tags vX.Y.Z, which the hive sync workflow pushes when hive is tagged +# crux/vX.Y.Z. The Go code in this repository is a synced copy of +# hivecommons/hive src/crux; only this scaffolding is maintained here. +version: 2 + +project_name: crux + +before: + hooks: + - go mod download + +builds: + - id: crux + main: ./cmd/crux + binary: crux + env: + - CGO_ENABLED=0 + goos: [linux, darwin] + goarch: [amd64, arm64] + flags: [-trimpath] + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} + mod_timestamp: "{{ .CommitTimestamp }}" + +archives: + - id: tar + formats: [tar.gz] + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + files: + - LICENSE + - README.md + +checksum: + name_template: checksums.txt + algorithm: sha256 + +signs: + - cmd: cosign + certificate: "${artifact}.pem" + args: + - sign-blob + - --yes + - --output-certificate=${certificate} + - --output-signature=${signature} + - ${artifact} + artifacts: checksum + output: true + +dockers: + - id: linux-amd64 + goos: linux + goarch: amd64 + use: buildx + dockerfile: Dockerfile.goreleaser + image_templates: + - ghcr.io/hivecommons/crux:{{ .Version }}-amd64 + build_flag_templates: + - --platform=linux/amd64 + - --label=org.opencontainers.image.title=crux + - --label=org.opencontainers.image.source=https://github.com/hivecommons/crux + - --label=org.opencontainers.image.version={{ .Version }} + - --label=org.opencontainers.image.revision={{ .FullCommit }} + - --label=org.opencontainers.image.licenses=Apache-2.0 + - id: linux-arm64 + goos: linux + goarch: arm64 + use: buildx + dockerfile: Dockerfile.goreleaser + image_templates: + - ghcr.io/hivecommons/crux:{{ .Version }}-arm64 + build_flag_templates: + - --platform=linux/arm64 + - --label=org.opencontainers.image.title=crux + - --label=org.opencontainers.image.source=https://github.com/hivecommons/crux + - --label=org.opencontainers.image.version={{ .Version }} + - --label=org.opencontainers.image.revision={{ .FullCommit }} + - --label=org.opencontainers.image.licenses=Apache-2.0 + +docker_manifests: + - name_template: ghcr.io/hivecommons/crux:{{ .Version }} + image_templates: + - ghcr.io/hivecommons/crux:{{ .Version }}-amd64 + - ghcr.io/hivecommons/crux:{{ .Version }}-arm64 + - name_template: ghcr.io/hivecommons/crux:latest + skip_push: "{{ if .Prerelease }}true{{ end }}" + image_templates: + - ghcr.io/hivecommons/crux:{{ .Version }}-amd64 + - ghcr.io/hivecommons/crux:{{ .Version }}-arm64 + +docker_signs: + - cmd: cosign + artifacts: manifests + args: [sign, --yes, "${artifact}"] + output: true + +changelog: + use: github-native + +release: + github: + owner: hivecommons + name: crux + prerelease: auto + footer: | + --- + **Source of truth:** this repository is a synced, module-path-rewritten copy of + [hivecommons/hive `src/crux`](https://github.com/hivecommons/hive/tree/v5/src/crux). + The hive commit this release was cut from is recorded in `.hive-sync` at the tag. + + ```sh + brew install hivecommons/hive/crux + go install github.com/hivecommons/crux/cmd/crux@{{ .Tag }} + docker run --rm ghcr.io/hivecommons/crux:{{ .Version }} --help + ``` diff --git a/.hive-sync b/.hive-sync new file mode 100644 index 0000000..983cbdb --- /dev/null +++ b/.hive-sync @@ -0,0 +1,8 @@ +# Generated by hivecommons/hive src/scripts/crux-publish.sh — do not edit. +# This tree is a module-path-rewritten copy of hivecommons/hive src/crux. +# File issues and pull requests in hivecommons/hive. +source=https://github.com/hivecommons/hive/tree/8bce0b07743f5530ba3a6377bbb65630a80c5ec6/src/crux +hive_sha=8bce0b07743f5530ba3a6377bbb65630a80c5ec6 +hive_ref=v5 +synced_at=2026-10-25T12:24:34Z +module_rewrite=github.com/hivecommons/hive/crux -> github.com/hivecommons/crux diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee3f06b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# ghcr.io/hivecommons/crux — the crux binary plus tmux, which the runtime +# drives to host agent processes. Built by goreleaser (.goreleaser.yaml) from +# the already cross-compiled binary for each platform; `docker build` from a +# checkout works too via the builder stage. +ARG GO_IMAGE=golang:1.26-alpine + +FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS build +ARG TARGETOS TARGETARCH VERSION=dev +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ + go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/crux ./cmd/crux + +FROM alpine:3.22 +RUN apk add --no-cache tmux ca-certificates git openssh-client bash \ + && adduser -D -u 1000 crux +COPY --from=build /out/crux /usr/local/bin/crux +USER crux +WORKDIR /home/crux +ENV CRUX_SOCKET=/home/crux/crux.sock +ENTRYPOINT ["crux"] +CMD ["--help"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser new file mode 100644 index 0000000..e7767c2 --- /dev/null +++ b/Dockerfile.goreleaser @@ -0,0 +1,12 @@ +# Used only by goreleaser (.goreleaser.yaml), which copies in the binary it +# already cross-compiled for the target platform. Keep the runtime layer in +# step with Dockerfile. +FROM alpine:3.22 +RUN apk add --no-cache tmux ca-certificates git openssh-client bash \ + && adduser -D -u 1000 crux +COPY crux /usr/local/bin/crux +USER crux +WORKDIR /home/crux +ENV CRUX_SOCKET=/home/crux/crux.sock +ENTRYPOINT ["crux"] +CMD ["--help"] diff --git a/activity.go b/activity.go new file mode 100644 index 0000000..a3d365c --- /dev/null +++ b/activity.go @@ -0,0 +1,13 @@ +package crux + +import "time" + +// ActivityObserver exposes the liveness and capability evidence used by the +// spoke's heartbeat. It does not launch or modify agent processes. +type ActivityObserver interface { + SessionMissing(name string) bool + AgentCapabilities(name string) (canOpenIssue, canOpenPR, canMerge, ok bool) + EffectiveBackend(name string) (backend string, ok bool) + AgentCredentialRefreshable(name string) (refreshable, known bool) + RestartTelemetry(name string) (total, last24h int, lastRestartAt time.Time, lastReason string, ok bool) +} diff --git a/agent/.hive-agent-stubs-1969186452/bob b/agent/.hive-agent-stubs-1969186452/bob new file mode 100755 index 0000000..d868679 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/bob @@ -0,0 +1,2 @@ +#!/bin/sh +exec cat diff --git a/agent/.hive-agent-stubs-1969186452/claude b/agent/.hive-agent-stubs-1969186452/claude new file mode 100755 index 0000000..d868679 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/claude @@ -0,0 +1,2 @@ +#!/bin/sh +exec cat diff --git a/agent/.hive-agent-stubs-1969186452/copilot b/agent/.hive-agent-stubs-1969186452/copilot new file mode 100755 index 0000000..d868679 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/copilot @@ -0,0 +1,2 @@ +#!/bin/sh +exec cat diff --git a/agent/.hive-agent-stubs-1969186452/gemini b/agent/.hive-agent-stubs-1969186452/gemini new file mode 100755 index 0000000..d868679 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/gemini @@ -0,0 +1,2 @@ +#!/bin/sh +exec cat diff --git a/agent/.hive-agent-stubs-1969186452/goose b/agent/.hive-agent-stubs-1969186452/goose new file mode 100755 index 0000000..d868679 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/goose @@ -0,0 +1,2 @@ +#!/bin/sh +exec cat diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-bootstrap-cxa.txt b/agent/.hive-agent-stubs-1969186452/perm/.hive-bootstrap-cxa.txt new file mode 100644 index 0000000..f82cf8f --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-bootstrap-cxa.txt @@ -0,0 +1 @@ +You are an AI agent. Wait for instructions from the supervisor. \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-a b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-a new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-agent b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-agent new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-bad b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-bad new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-covdiag b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-covdiag new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-covhang b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-covhang new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-cxa b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-cxa new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-env-test b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-env-test new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-guide b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-guide new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-inf b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-inf new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-inference-agent b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-inference-agent new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-kdfires b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-kdfires new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-nomodel b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-nomodel new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-quality b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-quality new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-reviewer b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-reviewer new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-scanner b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-scanner new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-schema b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-schema new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-sec-check b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-sec-check new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-sub b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-sub new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-test b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-test new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-test-agent b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-test-agent new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-tester b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-tester new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-worker b/agent/.hive-agent-stubs-1969186452/perm/.hive-caps-worker new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-a b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-a new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-a @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-agent b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-agent new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-agent @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-bad b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-bad new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-bad @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-covdiag b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-covdiag new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-covdiag @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-covhang b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-covhang new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-covhang @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-cxa b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-cxa new file mode 100644 index 0000000..314c7e4 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-cxa @@ -0,0 +1 @@ +ISSUES_ONLY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-env-test b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-env-test new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-env-test @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-guide b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-guide new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-guide @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-inf b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-inf new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-inf @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-inference-agent b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-inference-agent new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-inference-agent @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-kdfires b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-kdfires new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-kdfires @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-nomodel b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-nomodel new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-nomodel @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-quality b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-quality new file mode 100644 index 0000000..c53f92a --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-quality @@ -0,0 +1 @@ +ISSUES_AND_PRS \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-reviewer b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-reviewer new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-reviewer @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-scanner b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-scanner new file mode 100644 index 0000000..c53f92a --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-scanner @@ -0,0 +1 @@ +ISSUES_AND_PRS \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-schema b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-schema new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-schema @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-sec-check b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-sec-check new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-sec-check @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-sub b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-sub new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-sub @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-test b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-test new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-test @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-test-agent b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-test-agent new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-test-agent @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-tester b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-tester new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-tester @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-worker b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-worker new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/perm/.hive-mode-worker @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-a-never-created.jsonl b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-a-never-created.jsonl new file mode 100644 index 0000000..a4a59ea --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-a-never-created.jsonl @@ -0,0 +1,5 @@ +{"v":1,"ts":"2026-10-25T12:31:40.510Z","seq":1,"pid":76875,"session":"hive-a-never-created","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:31:40.517Z","seq":2,"pid":76875,"session":"hive-a-never-created","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='claude' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claude --model "}} +{"v":1,"ts":"2026-10-25T12:31:40.517Z","seq":3,"pid":76875,"session":"hive-a-never-created","pane":"0","source":"watch","type":"raw_output","data":{"line":" --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash(bootctl: "}} +{"v":1,"ts":"2026-10-25T12:31:40.517Z","seq":4,"pid":76875,"session":"hive-a-never-created","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K*"}} +{"v":1,"ts":"2026-10-25T12:31:40.517Z","seq":5,"pid":76875,"session":"hive-a-never-created","pane":"0","source":"watch","type":"raw_output","data":{"line":"*),Bash(efibootmgr:*)'\u001b[?2004l"}} diff --git a/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-a.jsonl b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-a.jsonl new file mode 100644 index 0000000..9eb2395 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-a.jsonl @@ -0,0 +1,36 @@ +{"v":1,"ts":"2026-10-25T12:28:45.232Z","seq":1,"pid":66116,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"HOME=/data/home /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/copilot"}} +{"v":1,"ts":"2026-10-25T12:28:50.628Z","seq":1,"pid":66641,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:28:50.629Z","seq":2,"pid":66641,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='copilot' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/copilot --mode "}} +{"v":1,"ts":"2026-10-25T12:28:50.629Z","seq":3,"pid":66641,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kl"}} +{"v":1,"ts":"2026-10-25T12:28:50.629Z","seq":4,"pid":66641,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"l --no-auto-update --allow-all --deny-tool='github-mcp-server(create_pull_request)' --deny-tool='github-mcp-server(create_pull_request_with_copilot)' --deny-tool='github-mcp-server(merge_pull_request)' --deny-tool='github-mcp-server(create_issue)' --deny-tool='github-mcp-server(update_issue)' --deny-tool='github-mcp-server(add_issue_comment)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:28:50.896Z","seq":5,"pid":66641,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"Bad credentials"}} +{"v":1,"ts":"2026-10-25T12:28:50.896Z","seq":6,"pid":66641,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"❯ ready"}} +{"v":1,"ts":"2026-10-25T12:28:57.106Z","seq":1,"pid":67004,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"HOME=/data/home /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/copilot"}} +{"v":1,"ts":"2026-10-25T12:28:59.604Z","seq":2,"pid":67004,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:28:59.657Z","seq":3,"pid":67004,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b]2;andan02@Andrews-MacBook-Pro:/var/folders/4k/wf8bxrdn14q9t_kgz2j50pj40000gn/T/TestRunCopilotDiagnostic_CLIReady936890055/001/cxa\u0007\u001b]1;..90055/001/cxa\u0007\u001b]7;file://Andrews-MacBook-Pro.local/var/folders/4k/wf8bxrdn14q9t_kgz2j50pj40000gn/T/TestRunCopilotDiagnostic_CLIReady936890055/001/cxa\u001b\\"}} +{"v":1,"ts":"2026-10-25T12:28:59.711Z","seq":4,"pid":67004,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[J\u001b[0m\u001b[49m\u001b[39m\u001b[0m\u001b[40m\u001b[33m andan02@Andrews-MacBook-Pro\u001b[0m\u001b[33m\u001b[40m\u001b[40m\u001b[33m \u001b[0m\u001b[33m\u001b[40m\u001b[44m\u001b[30m\u001b[0m\u001b[30m\u001b[44m\u001b[44m\u001b[30m /var/folders/4k/wf8bxrdn14q9t_kgz2j50pj40000gn/T/TestRunCopilotDiagnostic_CLIReady936890055/001/cxa\u001b[0m\u001b[30m\u001b[44m\u001b[44m\u001b[30m \u001b[0m\u001b[30m\u001b[44m\u001b[49m\u001b[34m\u001b[0m\u001b[34m\u001b[49m\u001b[39m \u001b[0m\u001b[49m\u001b[39m\u001b[K\u001b[268C\u001b[0m\u001b[49m\u001b[35m\u001b[0m\u001b[35m\u001b[45m\u001b[37m hive-inference/api-fmaas-vllm-d-fmaas-res-ibm-com:6443/Andrew.Anderson@ibm.com/hive-inference\u001b[0m\u001b[37m\u001b[45m\u001b[45m\u001b[37m ⎈\u001b[0m\u001b[37m\u001b[45m\u001b[45m\u001b[37m \u001b[0m\u001b[37m\u001b[45m\u001b[49m\u001b[39m\u001b[366D\u001b[?1h\u001b=\u001b[?2004hH\bHOME=/data/home /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/copilot\u001b[?1l\u001b>\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:28:59.746Z","seq":5,"pid":67004,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b]1;/Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/copilot\u0007Copilot v1.0"}} +{"v":1,"ts":"2026-10-25T12:28:59.746Z","seq":6,"pid":67004,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"❯ ready"}} +{"v":1,"ts":"2026-10-25T12:29:02.743Z","seq":1,"pid":67668,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:29:02.744Z","seq":2,"pid":67668,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='copilot' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/copilot --mode "}} +{"v":1,"ts":"2026-10-25T12:29:02.745Z","seq":3,"pid":67668,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kl"}} +{"v":1,"ts":"2026-10-25T12:29:02.745Z","seq":4,"pid":67668,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"l --no-auto-update --allow-all --deny-tool='github-mcp-server(create_pull_request)' --deny-tool='github-mcp-server(create_pull_request_with_copilot)' --deny-tool='github-mcp-server(merge_pull_request)' --deny-tool='github-mcp-server(create_issue)' --deny-tool='github-mcp-server(update_issue)' --deny-tool='github-mcp-server(add_issue_comment)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:29:02.773Z","seq":5,"pid":67668,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"Copilot v1.0"}} +{"v":1,"ts":"2026-10-25T12:29:02.774Z","seq":6,"pid":67668,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"❯ ready"}} +{"v":1,"ts":"2026-10-25T12:29:59.477Z","seq":1,"pid":70333,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"HOME=/data/home /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/copilot"}} +{"v":1,"ts":"2026-10-25T12:30:04.176Z","seq":1,"pid":71246,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:30:04.215Z","seq":2,"pid":71246,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='copilot' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/copilot --mode "}} +{"v":1,"ts":"2026-10-25T12:30:04.215Z","seq":3,"pid":71246,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kl"}} +{"v":1,"ts":"2026-10-25T12:30:04.215Z","seq":4,"pid":71246,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"l --no-auto-update --allow-all --deny-tool='github-mcp-server(create_pull_request)' --deny-tool='github-mcp-server(create_pull_request_with_copilot)' --deny-tool='github-mcp-server(merge_pull_request)' --deny-tool='github-mcp-server(create_issue)' --deny-tool='github-mcp-server(update_issue)' --deny-tool='github-mcp-server(add_issue_comment)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:34:13.246Z","seq":1,"pid":91859,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:34:13.356Z","seq":2,"pid":91859,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='a' HIVE_AGENT_DISPLAY_NAME='a' HIVE_BACKEND='claude' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='a' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claude --model --dan "}} +{"v":1,"ts":"2026-10-25T12:34:13.357Z","seq":3,"pid":91859,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kg"}} +{"v":1,"ts":"2026-10-25T12:34:13.432Z","seq":4,"pid":91859,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"gerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash(bootctl:*),Bas "}} +{"v":1,"ts":"2026-10-25T12:34:13.437Z","seq":5,"pid":91859,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kh"}} +{"v":1,"ts":"2026-10-25T12:34:13.669Z","seq":6,"pid":91859,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"h(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:34:22.891Z","seq":1,"pid":92642,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:34:22.914Z","seq":2,"pid":92642,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[Jandan02@Andrews-MacBook-Pro a % \u001b[K\u001b[?2004hH\bHIVE_AGENT='a' HIVE_AGENT_DISPLAY_NAME='a' HIVE_BACKEND='claude' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='a' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claude --m "}} +{"v":1,"ts":"2026-10-25T12:34:22.915Z","seq":3,"pid":92642,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ko"}} +{"v":1,"ts":"2026-10-25T12:34:22.975Z","seq":4,"pid":92642,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"odel --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash(boo "}} +{"v":1,"ts":"2026-10-25T12:34:22.975Z","seq":5,"pid":92642,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kt"}} +{"v":1,"ts":"2026-10-25T12:34:22.977Z","seq":6,"pid":92642,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"tctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} diff --git a/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-bad.jsonl b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-bad.jsonl new file mode 100644 index 0000000..37bc14a --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-bad.jsonl @@ -0,0 +1 @@ +{"v":1,"ts":"2026-10-25T12:32:15.411Z","seq":1,"pid":79473,"session":"hive-bad","pane":"0","source":"watch","type":"raw_output","data":{"line":"echo 'HIVE LAUNCH FAILED: backend not-a-real-backend did not launch: unknown backend: not-a-real-backend. This backend is not a supported CLI, built-in inference backend, or configured model gateway; switch this agent to a supported backend or configure a matching model gateway.'"}} diff --git a/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-covdiag.jsonl b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-covdiag.jsonl new file mode 100644 index 0000000..2fb2328 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-covdiag.jsonl @@ -0,0 +1 @@ +{"v":1,"ts":"2026-10-25T12:25:58.652Z","seq":1,"pid":57789,"session":"hive-covdiag","pane":"0","source":"watch","type":"raw_output","data":{"line":"HOME=/data/home /usr/local/bin/copilot"}} diff --git a/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-covhang.jsonl b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-covhang.jsonl new file mode 100644 index 0000000..6b01dc2 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-covhang.jsonl @@ -0,0 +1 @@ +{"v":1,"ts":"2026-10-25T12:32:35.857Z","seq":1,"pid":81592,"session":"hive-covhang","pane":"0","source":"watch","type":"raw_output","data":{"line":"HOME=/data/home /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/copilot"}} diff --git a/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-cxa.jsonl b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-cxa.jsonl new file mode 100644 index 0000000..9aff089 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-cxa.jsonl @@ -0,0 +1,42 @@ +{"v":1,"ts":"2026-10-25T12:26:10.234Z","seq":1,"pid":58968,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:26:10.247Z","seq":2,"pid":58968,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='claude' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claude --model "}} +{"v":1,"ts":"2026-10-25T12:26:10.247Z","seq":3,"pid":58968,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":" --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash(bootctl: "}} +{"v":1,"ts":"2026-10-25T12:26:10.247Z","seq":4,"pid":58968,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K*"}} +{"v":1,"ts":"2026-10-25T12:26:10.247Z","seq":5,"pid":58968,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"*),Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:29:09.216Z","seq":1,"pid":67925,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:29:09.220Z","seq":2,"pid":67925,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='goose' HIVE_MODEL='' HIVE_ACMM_LEVEL='5' HIVE_AGENT_MODE='ISSUES_AND_PRS' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_REPO='o/r' HIVE_REPOS='o/r' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive- "}} +{"v":1,"ts":"2026-10-25T12:29:09.221Z","seq":3,"pid":67925,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ka"}} +{"v":1,"ts":"2026-10-25T12:29:09.221Z","seq":4,"pid":67925,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"agent-stubs-1969186452/goose run -s --text \"$(cat /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/perm/.hive-bootstrap-cxa.txt)\"\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:29:15.804Z","seq":1,"pid":68079,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:29:15.815Z","seq":2,"pid":68079,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='goose' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/goose run -s --t "}} +{"v":1,"ts":"2026-10-25T12:29:15.815Z","seq":3,"pid":68079,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ke"}} +{"v":1,"ts":"2026-10-25T12:29:15.815Z","seq":4,"pid":68079,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"ext \"$(cat /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/perm/.hive-bootstrap-cxa.txt)\"\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:29:22.445Z","seq":1,"pid":68282,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:29:22.457Z","seq":2,"pid":68282,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='claude' HIVE_MODEL='opus' HIVE_ACMM_LEVEL='5' HIVE_AGENT_MODE='ISSUES_ONLY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claude - "}} +{"v":1,"ts":"2026-10-25T12:29:22.457Z","seq":3,"pid":68282,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K-"}} +{"v":1,"ts":"2026-10-25T12:29:22.458Z","seq":4,"pid":68282,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"-model opus --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Ba "}} +{"v":1,"ts":"2026-10-25T12:29:22.458Z","seq":5,"pid":68282,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ks"}} +{"v":1,"ts":"2026-10-25T12:29:22.458Z","seq":6,"pid":68282,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"sh(bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:29:28.778Z","seq":1,"pid":68654,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:29:28.779Z","seq":2,"pid":68654,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='claude' HIVE_MODEL='opus' HIVE_ACMM_LEVEL='5' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claude --mo "}} +{"v":1,"ts":"2026-10-25T12:29:28.779Z","seq":3,"pid":68654,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kd"}} +{"v":1,"ts":"2026-10-25T12:29:28.790Z","seq":4,"pid":68654,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"del opus --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash( "}} +{"v":1,"ts":"2026-10-25T12:29:28.791Z","seq":5,"pid":68654,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kb"}} +{"v":1,"ts":"2026-10-25T12:29:28.803Z","seq":6,"pid":68654,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:29:35.635Z","seq":1,"pid":68985,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:29:35.640Z","seq":2,"pid":68985,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='copilot' HIVE_MODEL='auto' HIVE_ACMM_LEVEL='1' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/copilot -- "}} +{"v":1,"ts":"2026-10-25T12:29:35.641Z","seq":3,"pid":68985,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Km"}} +{"v":1,"ts":"2026-10-25T12:29:35.662Z","seq":4,"pid":68985,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"model auto --no-auto-update --allow-all --deny-tool='github-mcp-server(create_pull_request)' --deny-tool='github-mcp-server(create_pull_request_with_copilot)' --deny-tool='github-mcp-server(merge_pull_request)' --deny-tool='github-mcp-server(create_issue)' --deny-tool='github-mcp-server(update_issue)' --deny-tool='github-mcp-server(add_issue_comment)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:29:41.950Z","seq":1,"pid":69264,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:29:42.015Z","seq":2,"pid":69264,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='claude' HIVE_MODEL='opus' HIVE_ACMM_LEVEL='5' HIVE_AGENT_MODE='ISSUES_AND_PRS' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claud "}} +{"v":1,"ts":"2026-10-25T12:29:42.016Z","seq":3,"pid":69264,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ke"}} +{"v":1,"ts":"2026-10-25T12:29:42.016Z","seq":4,"pid":69264,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"e --model opus --dangerously-skip-permissions --disallowed-tools 'mcp__github__merge_pull_request'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:29:50.322Z","seq":1,"pid":69659,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:29:50.483Z","seq":2,"pid":69659,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='claude' HIVE_MODEL='opus' HIVE_ACMM_LEVEL='5' HIVE_AGENT_MODE='ISSUES_AND_PRS' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claud "}} +{"v":1,"ts":"2026-10-25T12:29:50.515Z","seq":3,"pid":69659,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ke"}} +{"v":1,"ts":"2026-10-25T12:29:50.520Z","seq":4,"pid":69659,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"e --model opus --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*) "}} +{"v":1,"ts":"2026-10-25T12:29:50.520Z","seq":5,"pid":69659,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K,"}} +{"v":1,"ts":"2026-10-25T12:29:50.520Z","seq":6,"pid":69659,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":",Bash(bootctl:*),Bash(efibootmgr:*)' --mcp-server 'https://mcp.example'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:35:11.426Z","seq":1,"pid":96179,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:35:11.433Z","seq":2,"pid":96179,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='copilot' HIVE_MODEL='auto' HIVE_ACMM_LEVEL='3' HIVE_AGENT_MODE='ISSUES_ONLY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/copilot "}} +{"v":1,"ts":"2026-10-25T12:35:11.433Z","seq":3,"pid":96179,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":" --model auto --no-auto-update --allow-all --deny-tool='github-mcp-server(create_pull_request)' --deny-tool='github-mcp-server(create_pull_request_with_copilot)' --deny-tool='github-mcp-server(merge_pull_request)' --deny-tool='github-mcp-server(create_issue)' --deny-tool='github-mcp-server(update_issue)' --deny-tool='github-mcp-server(add_issue_comment)'\u001b[?2004l"}} diff --git a/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-kdfires.jsonl b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-kdfires.jsonl new file mode 100644 index 0000000..6806928 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-kdfires.jsonl @@ -0,0 +1 @@ +{"v":1,"ts":"2026-10-25T12:32:02.304Z","seq":1,"pid":78788,"session":"hive-kdfires","pane":"0","source":"watch","type":"raw_output","data":{"line":"HOME=/data/home /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/copilot"}} diff --git a/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-reviewer.jsonl b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-reviewer.jsonl new file mode 100644 index 0000000..6bd777c --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-reviewer.jsonl @@ -0,0 +1 @@ +{"v":1,"ts":"2026-10-25T12:30:14.619Z","seq":1,"pid":71926,"session":"hive-reviewer","pane":"0","source":"watch","type":"raw_output","data":{"line":"echo 'HIVE LAUNCH FAILED: backend no-such-backend did not launch: unknown backend: no-such-backend. This backend is not a supported CLI, built-in inference backend, or configured model gateway; switch this agent to a supported backend or configure a matching model gateway.'"}} diff --git a/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-scanner-kicklog-test.jsonl b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-scanner-kicklog-test.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-scanner.jsonl b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-scanner.jsonl new file mode 100644 index 0000000..05ead2e --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-scanner.jsonl @@ -0,0 +1,38 @@ +{"v":1,"ts":"2026-10-25T12:25:25.807Z","seq":1,"pid":54956,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:25:25.820Z","seq":2,"pid":54956,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='scanner' HIVE_AGENT_DISPLAY_NAME='scanner' HIVE_BACKEND='bob' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='scanner' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' BOBSHELL_DEFAULT_AUTH_TYPE='api-key' HIVE_BOB_DISPLAY_NAME='scanner' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' sh -c 'case \"$( "}} +{"v":1,"ts":"2026-10-25T12:25:25.821Z","seq":3,"pid":54956,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K/"}} +{"v":1,"ts":"2026-10-25T12:25:25.821Z","seq":4,"pid":54956,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"/Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/bob --version 2>/dev/null | sed -n 1p)\" in 1.*) exec /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/bob --accept-license --auth-method api-key --approval-mode yolo --trust ;; *) exec /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/bob chat --accept-license --auto-approve --trust ;; esac'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:25:32.544Z","seq":1,"pid":55310,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:25:32.555Z","seq":2,"pid":55310,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='scanner' HIVE_AGENT_DISPLAY_NAME='scanner' HIVE_BACKEND='bob' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='scanner' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' BOBSHELL_DEFAULT_AUTH_TYPE='api-key' HIVE_BOB_DISPLAY_NAME='scanner' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' sh -c 'case \"$( "}} +{"v":1,"ts":"2026-10-25T12:25:32.556Z","seq":3,"pid":55310,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K/"}} +{"v":1,"ts":"2026-10-25T12:25:32.556Z","seq":4,"pid":55310,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"/Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/bob --version 2>/dev/null | sed -n 1p)\" in 1.*) exec /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/bob --accept-license --auth-method api-key --approval-mode yolo --trust ;; *) exec /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/bob chat --accept-license --auto-approve --trust ;; esac'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:25:54.126Z","seq":1,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:25:54.137Z","seq":2,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='scanner' HIVE_AGENT_DISPLAY_NAME='scanner' HIVE_BACKEND='claude' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='scanner' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' printf 'goose is ready\\n'; cat\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:25:54.138Z","seq":3,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"goose is ready"}} +{"v":1,"ts":"2026-10-25T12:25:54.478Z","seq":4,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C"}} +{"v":1,"ts":"2026-10-25T12:25:54.478Z","seq":5,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:25:54.580Z","seq":6,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hg\bgo drain the queue from real Start\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:25:54.736Z","seq":7,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"go drain: unknown command"}} +{"v":1,"ts":"2026-10-25T12:25:54.736Z","seq":8,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"Run 'go help' for usage."}} +{"v":1,"ts":"2026-10-25T12:25:54.738Z","seq":9,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:25:54.738Z","seq":10,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004h\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:25:54.740Z","seq":11,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:25:54.742Z","seq":12,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004h\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:25:54.742Z","seq":13,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:25:54.757Z","seq":14,"pid":57180,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004h"}} +{"v":1,"ts":"2026-10-25T12:30:11.711Z","seq":1,"pid":71649,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"echo 'HIVE LAUNCH FAILED: backend no-such-backend did not launch: unknown backend: no-such-backend. This backend is not a supported CLI, built-in inference backend, or configured model gateway; switch this agent to a supported backend or configure a matching model gateway.'"}} +{"v":1,"ts":"2026-10-25T12:30:12.840Z","seq":1,"pid":71778,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"echo 'HIVE LAUNCH FAILED: backend no-such-backend did not launch: unknown backend: no-such-backend. This backend is not a supported CLI, built-in inference backend, or configured model gateway; switch this agent to a supported backend or configure a matching model gateway.'"}} +{"v":1,"ts":"2026-10-25T12:30:16.370Z","seq":1,"pid":72106,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"echo 'HIVE LAUNCH FAILED: backend no-such-backend did not launch: unknown backend: no-such-backend. This backend is not a supported CLI, built-in inference backend, or configured model gateway; switch this agent to a supported backend or configure a matching model gateway.'"}} +{"v":1,"ts":"2026-10-25T12:32:16.555Z","seq":1,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:32:16.565Z","seq":2,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='scanner' HIVE_AGENT_DISPLAY_NAME='scanner' HIVE_BACKEND='claude' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='scanner' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/cla "}} +{"v":1,"ts":"2026-10-25T12:32:16.565Z","seq":3,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ku"}} +{"v":1,"ts":"2026-10-25T12:32:16.578Z","seq":4,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"ude --model --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),B "}} +{"v":1,"ts":"2026-10-25T12:32:16.578Z","seq":5,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ka"}} +{"v":1,"ts":"2026-10-25T12:32:16.590Z","seq":6,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"ash(bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:35:02.777Z","seq":7,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C"}} +{"v":1,"ts":"2026-10-25T12:35:02.779Z","seq":8,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:35:02.845Z","seq":9,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='scanner' HIVE_AGENT_DISPLAY_NAME='scanner' HIVE_BACKEND='claude' HIVE_MODEL='opus' HIVE_ACMM_LEVEL='5' HIVE_AGENT_MODE='ISSUES_AND_PRS' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='scanner' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969 "}} +{"v":1,"ts":"2026-10-25T12:35:02.846Z","seq":10,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K1"}} +{"v":1,"ts":"2026-10-25T12:35:02.865Z","seq":11,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"186452/claude --model opus --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Ba "}} +{"v":1,"ts":"2026-10-25T12:35:02.865Z","seq":12,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ks"}} +{"v":1,"ts":"2026-10-25T12:35:02.922Z","seq":13,"pid":79606,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"sh(grubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} diff --git a/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-worker.jsonl b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-worker.jsonl new file mode 100644 index 0000000..fc55c31 --- /dev/null +++ b/agent/.hive-agent-stubs-1969186452/pluk/logs/hive-worker.jsonl @@ -0,0 +1,77 @@ +{"v":1,"ts":"2026-10-25T12:30:38.923Z","seq":1,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh: /Users/andan02/.deno/env: No such file or directory"}} +{"v":1,"ts":"2026-10-25T12:30:38.957Z","seq":2,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[?1034hAndrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:30:38.957Z","seq":3,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ HIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-st "}} +{"v":1,"ts":"2026-10-25T12:30:38.957Z","seq":4,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"ubs-1969186452/claude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(os "}} +{"v":1,"ts":"2026-10-25T12:30:38.957Z","seq":5,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"tree:*),Bash(grubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'"}} +{"v":1,"ts":"2026-10-25T12:30:42.852Z","seq":6,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":": goose is ready"}} +{"v":1,"ts":"2026-10-25T12:30:42.853Z","seq":7,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":": goose is ready"}} +{"v":1,"ts":"2026-10-25T12:30:47.347Z","seq":8,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C"}} +{"v":1,"ts":"2026-10-25T12:30:47.538Z","seq":9,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ \u0007after the restart"}} +{"v":1,"ts":"2026-10-25T12:30:47.551Z","seq":10,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh: after: command not found"}} +{"v":1,"ts":"2026-10-25T12:30:47.667Z","seq":11,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:30:47.765Z","seq":12,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:30:47.869Z","seq":13,"pid":73369,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:30:58.362Z","seq":1,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh: /Users/andan02/.deno/env: No such file or directory"}} +{"v":1,"ts":"2026-10-25T12:30:58.364Z","seq":2,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[?1034hAndrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:30:58.365Z","seq":3,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ HIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-st "}} +{"v":1,"ts":"2026-10-25T12:30:58.371Z","seq":4,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"ubs-1969186452/claude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(os "}} +{"v":1,"ts":"2026-10-25T12:30:58.376Z","seq":5,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"tree:*),Bash(grubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'"}} +{"v":1,"ts":"2026-10-25T12:31:02.736Z","seq":6,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":": goose is ready"}} +{"v":1,"ts":"2026-10-25T12:31:02.760Z","seq":7,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":": goose is ready"}} +{"v":1,"ts":"2026-10-25T12:31:02.967Z","seq":8,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C"}} +{"v":1,"ts":"2026-10-25T12:31:03.503Z","seq":9,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ \u0007recover and continue the sweep"}} +{"v":1,"ts":"2026-10-25T12:31:03.506Z","seq":10,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh: recover: command not found"}} +{"v":1,"ts":"2026-10-25T12:31:03.506Z","seq":11,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:31:03.507Z","seq":12,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:31:03.508Z","seq":13,"pid":74484,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:31:10.422Z","seq":1,"pid":75139,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[?1034h-sh-3.2$ HIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claude --mod "}} +{"v":1,"ts":"2026-10-25T12:31:10.431Z","seq":2,"pid":75139,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"el sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash "}} +{"v":1,"ts":"2026-10-25T12:31:10.435Z","seq":3,"pid":75139,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"(bootctl:*),Bash(efibootmgr:*)'"}} +{"v":1,"ts":"2026-10-25T12:31:14.703Z","seq":4,"pid":75139,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":": goose is ready"}} +{"v":1,"ts":"2026-10-25T12:31:14.705Z","seq":5,"pid":75139,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":": goose is ready"}} +{"v":1,"ts":"2026-10-25T12:31:15.246Z","seq":6,"pid":75139,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C"}} +{"v":1,"ts":"2026-10-25T12:31:15.463Z","seq":7,"pid":75139,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh-3.2$ \u0007fresh context, take the next issue"}} +{"v":1,"ts":"2026-10-25T12:31:15.466Z","seq":8,"pid":75139,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh: fresh: command not found"}} +{"v":1,"ts":"2026-10-25T12:31:15.562Z","seq":9,"pid":75139,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh-3.2$ "}} +{"v":1,"ts":"2026-10-25T12:31:15.688Z","seq":10,"pid":75139,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh-3.2$ "}} +{"v":1,"ts":"2026-10-25T12:31:15.735Z","seq":11,"pid":75139,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh-3.2$ "}} +{"v":1,"ts":"2026-10-25T12:32:14.451Z","seq":1,"pid":79350,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:32:14.453Z","seq":2,"pid":79350,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/ "}} +{"v":1,"ts":"2026-10-25T12:32:14.453Z","seq":3,"pid":79350,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kc"}} +{"v":1,"ts":"2026-10-25T12:32:14.453Z","seq":4,"pid":79350,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"claude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(gr "}} +{"v":1,"ts":"2026-10-25T12:32:14.453Z","seq":5,"pid":79350,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ku"}} +{"v":1,"ts":"2026-10-25T12:32:14.453Z","seq":6,"pid":79350,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"ubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:32:53.550Z","seq":1,"pid":83469,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:32:53.709Z","seq":2,"pid":83469,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[Jandan02@Andrews-MacBook-Pro worker % \u001b[K\u001b[?2004hH\bHIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' HIVE_AGENT_TOKEN_CACHE='/var/run/hive-metrics/agent-tokens/gh- "}} +{"v":1,"ts":"2026-10-25T12:32:53.710Z","seq":3,"pid":83469,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kt"}} +{"v":1,"ts":"2026-10-25T12:32:53.728Z","seq":4,"pid":83469,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"token-worker.cache' HOME='/data/home/agents/worker' XDG_DATA_HOME='/data/home/agents/worker/.local/share' XDG_STATE_HOME='/data/home/agents/worker/.local/state' DISABLE_AUTOUPDATER='1' GIT_AUTHOR_NAME='worker' GIT_AUTHOR_EMAIL='worker@hive.kubestellar.io' GIT_COMMITTER_NAME='worker' GIT_COMMITTER_EMAIL='worker@hive.kubestellar.io' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__cre "}} +{"v":1,"ts":"2026-10-25T12:32:53.729Z","seq":5,"pid":83469,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ka"}} +{"v":1,"ts":"2026-10-25T12:32:53.841Z","seq":6,"pid":83469,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"ate_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:33:04.855Z","seq":1,"pid":84337,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:33:05.014Z","seq":2,"pid":84337,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[Jandan02@Andrews-MacBook-Pro worker % \u001b[K\u001b[?2004hH\bHIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' HIVE_AGENT_TOKEN_CACHE='/var/run/hive-metrics/agent-tokens/gh- "}} +{"v":1,"ts":"2026-10-25T12:33:05.015Z","seq":3,"pid":84337,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kt"}} +{"v":1,"ts":"2026-10-25T12:33:05.041Z","seq":4,"pid":84337,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"token-worker.cache' HOME='/data/home/agents/worker' XDG_DATA_HOME='/data/home/agents/worker/.local/share' XDG_STATE_HOME='/data/home/agents/worker/.local/state' DISABLE_AUTOUPDATER='1' GIT_AUTHOR_NAME='worker' GIT_AUTHOR_EMAIL='worker@hive.kubestellar.io' GIT_COMMITTER_NAME='worker' GIT_COMMITTER_EMAIL='worker@hive.kubestellar.io' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__cre "}} +{"v":1,"ts":"2026-10-25T12:33:05.041Z","seq":5,"pid":84337,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ka"}} +{"v":1,"ts":"2026-10-25T12:33:05.114Z","seq":6,"pid":84337,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"ate_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:33:15.726Z","seq":1,"pid":85278,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:33:15.901Z","seq":2,"pid":85278,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[Jandan02@Andrews-MacBook-Pro worker % \u001b[K\u001b[?2004h\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:33:15.901Z","seq":3,"pid":85278,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:33:16.036Z","seq":4,"pid":85278,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[Jandan02@Andrews-MacBook-Pro worker % \u001b[K\u001b[?2004hH\bHIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' HIVE_AGENT_TOKEN_CACHE='/var/run/hive-metrics/agent-tokens/gh- "}} +{"v":1,"ts":"2026-10-25T12:33:16.052Z","seq":5,"pid":85278,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kt"}} +{"v":1,"ts":"2026-10-25T12:33:16.058Z","seq":6,"pid":85278,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"token-worker.cache' HOME='/data/home/agents/worker' XDG_DATA_HOME='/data/home/agents/worker/.local/share' XDG_STATE_HOME='/data/home/agents/worker/.local/state' DISABLE_AUTOUPDATER='1' GIT_AUTHOR_NAME='worker' GIT_AUTHOR_EMAIL='worker@hive.kubestellar.io' GIT_COMMITTER_NAME='worker' GIT_COMMITTER_EMAIL='worker@hive.kubestellar.io' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__cre "}} +{"v":1,"ts":"2026-10-25T12:33:16.059Z","seq":7,"pid":85278,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ka"}} +{"v":1,"ts":"2026-10-25T12:33:16.111Z","seq":8,"pid":85278,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"ate_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:33:26.572Z","seq":1,"pid":86640,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:33:26.721Z","seq":2,"pid":86640,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[Jandan02@Andrews-MacBook-Pro worker % \u001b[K\u001b[?2004hH\bHIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' HIVE_AGENT_TOKEN_CACHE='/var/run/hive-metrics/agent-tokens/gh- "}} +{"v":1,"ts":"2026-10-25T12:33:26.721Z","seq":3,"pid":86640,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kt"}} +{"v":1,"ts":"2026-10-25T12:33:26.738Z","seq":4,"pid":86640,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"token-worker.cache' HOME='/data/home/agents/worker' XDG_DATA_HOME='/data/home/agents/worker/.local/share' XDG_STATE_HOME='/data/home/agents/worker/.local/state' DISABLE_AUTOUPDATER='1' GIT_AUTHOR_NAME='worker' GIT_AUTHOR_EMAIL='worker@hive.kubestellar.io' GIT_COMMITTER_NAME='worker' GIT_COMMITTER_EMAIL='worker@hive.kubestellar.io' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/claude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__cre "}} +{"v":1,"ts":"2026-10-25T12:33:26.738Z","seq":5,"pid":86640,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ka"}} +{"v":1,"ts":"2026-10-25T12:33:26.824Z","seq":6,"pid":86640,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"ate_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:33:43.855Z","seq":1,"pid":88468,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:33:43.858Z","seq":2,"pid":88468,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-1969186452/ "}} +{"v":1,"ts":"2026-10-25T12:33:43.858Z","seq":3,"pid":88468,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kc"}} +{"v":1,"ts":"2026-10-25T12:33:43.858Z","seq":4,"pid":88468,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"claude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(gr "}} +{"v":1,"ts":"2026-10-25T12:33:43.858Z","seq":5,"pid":88468,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ku"}} +{"v":1,"ts":"2026-10-25T12:33:43.858Z","seq":6,"pid":88468,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"ubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:33:57.861Z","seq":1,"pid":90182,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:33:57.866Z","seq":2,"pid":90182,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[Jandan02@Andrews-MacBook-Pro worker % \u001b[K\u001b[?2004h"}} diff --git a/agent/.hive-agent-stubs-6766624/bob b/agent/.hive-agent-stubs-6766624/bob new file mode 100755 index 0000000..d868679 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/bob @@ -0,0 +1,2 @@ +#!/bin/sh +exec cat diff --git a/agent/.hive-agent-stubs-6766624/claude b/agent/.hive-agent-stubs-6766624/claude new file mode 100755 index 0000000..d868679 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/claude @@ -0,0 +1,2 @@ +#!/bin/sh +exec cat diff --git a/agent/.hive-agent-stubs-6766624/copilot b/agent/.hive-agent-stubs-6766624/copilot new file mode 100755 index 0000000..d868679 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/copilot @@ -0,0 +1,2 @@ +#!/bin/sh +exec cat diff --git a/agent/.hive-agent-stubs-6766624/gemini b/agent/.hive-agent-stubs-6766624/gemini new file mode 100755 index 0000000..d868679 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/gemini @@ -0,0 +1,2 @@ +#!/bin/sh +exec cat diff --git a/agent/.hive-agent-stubs-6766624/goose b/agent/.hive-agent-stubs-6766624/goose new file mode 100755 index 0000000..d868679 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/goose @@ -0,0 +1,2 @@ +#!/bin/sh +exec cat diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-bootstrap-cxa.txt b/agent/.hive-agent-stubs-6766624/perm/.hive-bootstrap-cxa.txt new file mode 100644 index 0000000..f82cf8f --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-bootstrap-cxa.txt @@ -0,0 +1 @@ +You are an AI agent. Wait for instructions from the supervisor. \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-a b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-a new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-agent b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-agent new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-bad b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-bad new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-covdiag b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-covdiag new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-cxa b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-cxa new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-env-test b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-env-test new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-guide b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-guide new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-inf b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-inf new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-inference-agent b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-inference-agent new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-kdfires b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-kdfires new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-nomodel b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-nomodel new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-quality b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-quality new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-reviewer b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-reviewer new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-scanner b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-scanner new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-schema b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-schema new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-sec-check b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-sec-check new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-sub b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-sub new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-test b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-test new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-test-agent b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-test-agent new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-tester b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-tester new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-caps-worker b/agent/.hive-agent-stubs-6766624/perm/.hive-caps-worker new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-a b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-a new file mode 100644 index 0000000..c53f92a --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-a @@ -0,0 +1 @@ +ISSUES_AND_PRS \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-agent b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-agent new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-agent @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-bad b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-bad new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-bad @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-covdiag b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-covdiag new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-covdiag @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-cxa b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-cxa new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-cxa @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-env-test b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-env-test new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-env-test @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-guide b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-guide new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-guide @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-inf b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-inf new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-inf @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-inference-agent b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-inference-agent new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-inference-agent @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-kdfires b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-kdfires new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-kdfires @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-nomodel b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-nomodel new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-nomodel @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-quality b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-quality new file mode 100644 index 0000000..c53f92a --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-quality @@ -0,0 +1 @@ +ISSUES_AND_PRS \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-reviewer b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-reviewer new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-reviewer @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-scanner b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-scanner new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-scanner @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-schema b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-schema new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-schema @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-sec-check b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-sec-check new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-sec-check @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-sub b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-sub new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-sub @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-test b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-test new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-test @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-test-agent b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-test-agent new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-test-agent @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-tester b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-tester new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-tester @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/perm/.hive-mode-worker b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-worker new file mode 100644 index 0000000..db026e0 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/perm/.hive-mode-worker @@ -0,0 +1 @@ +ADVISORY \ No newline at end of file diff --git a/agent/.hive-agent-stubs-6766624/pluk/logs/hive-a-never-created.jsonl b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-a-never-created.jsonl new file mode 100644 index 0000000..26e3018 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-a-never-created.jsonl @@ -0,0 +1,6 @@ +{"v":1,"ts":"2026-10-25T12:39:08.414Z","seq":1,"pid":22542,"session":"hive-a-never-created","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:39:08.483Z","seq":2,"pid":22542,"session":"hive-a-never-created","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='claude' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/claude --model -- "}} +{"v":1,"ts":"2026-10-25T12:39:08.483Z","seq":3,"pid":22542,"session":"hive-a-never-created","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kd"}} +{"v":1,"ts":"2026-10-25T12:39:08.483Z","seq":4,"pid":22542,"session":"hive-a-never-created","pane":"0","source":"watch","type":"raw_output","data":{"line":"dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash(bootctl:*), "}} +{"v":1,"ts":"2026-10-25T12:39:08.483Z","seq":5,"pid":22542,"session":"hive-a-never-created","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[KB"}} +{"v":1,"ts":"2026-10-25T12:39:08.483Z","seq":6,"pid":22542,"session":"hive-a-never-created","pane":"0","source":"watch","type":"raw_output","data":{"line":"Bash(efibootmgr:*)'\u001b[?2004l"}} diff --git a/agent/.hive-agent-stubs-6766624/pluk/logs/hive-a.jsonl b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-a.jsonl new file mode 100644 index 0000000..45b5603 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-a.jsonl @@ -0,0 +1,22 @@ +{"v":1,"ts":"2026-10-25T12:36:24.430Z","seq":1,"pid":3135,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"HOME=/data/home /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/copilot"}} +{"v":1,"ts":"2026-10-25T12:36:28.160Z","seq":1,"pid":3800,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:36:28.162Z","seq":2,"pid":3800,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='copilot' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/copilot --model "}} +{"v":1,"ts":"2026-10-25T12:36:28.162Z","seq":3,"pid":3800,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K-"}} +{"v":1,"ts":"2026-10-25T12:36:28.162Z","seq":4,"pid":3800,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"--no-auto-update --allow-all --deny-tool='github-mcp-server(create_pull_request)' --deny-tool='github-mcp-server(create_pull_request_with_copilot)' --deny-tool='github-mcp-server(merge_pull_request)' --deny-tool='github-mcp-server(create_issue)' --deny-tool='github-mcp-server(update_issue)' --deny-tool='github-mcp-server(add_issue_comment)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:36:33.678Z","seq":1,"pid":4132,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"HOME=/data/home /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/copilot"}} +{"v":1,"ts":"2026-10-25T12:36:35.549Z","seq":2,"pid":4132,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:36:35.607Z","seq":3,"pid":4132,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b]2;andan02@Andrews-MacBook-Pro:/var/folders/4k/wf8bxrdn14q9t_kgz2j50pj40000gn/T/TestRunCopilotDiagnostic_CLIReady2026871818/001/cxa\u0007\u001b]1;..71818/001/cxa\u0007\u001b]7;file://Andrews-MacBook-Pro.local/var/folders/4k/wf8bxrdn14q9t_kgz2j50pj40000gn/T/TestRunCopilotDiagnostic_CLIReady2026871818/001/cxa\u001b\\"}} +{"v":1,"ts":"2026-10-25T12:36:35.667Z","seq":4,"pid":4132,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[J\u001b[0m\u001b[49m\u001b[39m\u001b[0m\u001b[40m\u001b[33m andan02@Andrews-MacBook-Pro\u001b[0m\u001b[33m\u001b[40m\u001b[40m\u001b[33m \u001b[0m\u001b[33m\u001b[40m\u001b[44m\u001b[30m\u001b[0m\u001b[30m\u001b[44m\u001b[44m\u001b[30m /var/folders/4k/wf8bxrdn14q9t_kgz2j50pj40000gn/T/TestRunCopilotDiagnostic_CLIReady2026871818/001/cxa\u001b[0m\u001b[30m\u001b[44m\u001b[44m\u001b[30m \u001b[0m\u001b[30m\u001b[44m\u001b[49m\u001b[34m\u001b[0m\u001b[34m\u001b[49m\u001b[39m \u001b[0m\u001b[49m\u001b[39m\u001b[K\u001b[267C\u001b[0m\u001b[49m\u001b[35m\u001b[0m\u001b[35m\u001b[45m\u001b[37m hive-inference/api-fmaas-vllm-d-fmaas-res-ibm-com:6443/Andrew.Anderson@ibm.com/hive-inference\u001b[0m\u001b[37m\u001b[45m\u001b[45m\u001b[37m ⎈\u001b[0m\u001b[37m\u001b[45m\u001b[45m\u001b[37m \u001b[0m\u001b[37m\u001b[45m\u001b[49m\u001b[39m\u001b[365D\u001b[?1h\u001b=\u001b[?2004hH\bHOME=/data/home /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/copilot\u001b[?1l\u001b>\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:36:35.691Z","seq":5,"pid":4132,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b]1;/Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/copilot\u0007Copilot v1.0"}} +{"v":1,"ts":"2026-10-25T12:36:35.691Z","seq":6,"pid":4132,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"❯ ready"}} +{"v":1,"ts":"2026-10-25T12:36:36.170Z","seq":1,"pid":4681,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:36:36.171Z","seq":2,"pid":4681,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='copilot' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/copilot --model "}} +{"v":1,"ts":"2026-10-25T12:36:36.171Z","seq":3,"pid":4681,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K-"}} +{"v":1,"ts":"2026-10-25T12:36:36.171Z","seq":4,"pid":4681,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"--no-auto-update --allow-all --deny-tool='github-mcp-server(create_pull_request)' --deny-tool='github-mcp-server(create_pull_request_with_copilot)' --deny-tool='github-mcp-server(merge_pull_request)' --deny-tool='github-mcp-server(create_issue)' --deny-tool='github-mcp-server(update_issue)' --deny-tool='github-mcp-server(add_issue_comment)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:36:36.182Z","seq":5,"pid":4681,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"Copilot v1.0"}} +{"v":1,"ts":"2026-10-25T12:36:36.182Z","seq":6,"pid":4681,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"❯ ready"}} +{"v":1,"ts":"2026-10-25T12:37:32.401Z","seq":1,"pid":9999,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"HOME=/data/home /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/copilot"}} +{"v":1,"ts":"2026-10-25T12:37:39.253Z","seq":1,"pid":10731,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:37:39.256Z","seq":2,"pid":10731,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='copilot' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/copilot --model "}} +{"v":1,"ts":"2026-10-25T12:37:39.256Z","seq":3,"pid":10731,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K-"}} +{"v":1,"ts":"2026-10-25T12:37:39.265Z","seq":4,"pid":10731,"session":"hive-a","pane":"0","source":"watch","type":"raw_output","data":{"line":"--no-auto-update --allow-all --deny-tool='github-mcp-server(create_pull_request)' --deny-tool='github-mcp-server(create_pull_request_with_copilot)' --deny-tool='github-mcp-server(merge_pull_request)' --deny-tool='github-mcp-server(create_issue)' --deny-tool='github-mcp-server(update_issue)' --deny-tool='github-mcp-server(add_issue_comment)'\u001b[?2004l"}} diff --git a/agent/.hive-agent-stubs-6766624/pluk/logs/hive-bad.jsonl b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-bad.jsonl new file mode 100644 index 0000000..57ce551 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-bad.jsonl @@ -0,0 +1 @@ +{"v":1,"ts":"2026-10-25T12:39:47.159Z","seq":1,"pid":27237,"session":"hive-bad","pane":"0","source":"watch","type":"raw_output","data":{"line":"echo 'HIVE LAUNCH FAILED: backend not-a-real-backend did not launch: unknown backend: not-a-real-backend. This backend is not a supported CLI, built-in inference backend, or configured model gateway; switch this agent to a supported backend or configure a matching model gateway.'"}} diff --git a/agent/.hive-agent-stubs-6766624/pluk/logs/hive-covdiag.jsonl b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-covdiag.jsonl new file mode 100644 index 0000000..c2e5410 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-covdiag.jsonl @@ -0,0 +1 @@ +{"v":1,"ts":"2026-10-25T12:36:05.577Z","seq":1,"pid":1299,"session":"hive-covdiag","pane":"0","source":"watch","type":"raw_output","data":{"line":"HOME=/data/home /usr/local/bin/copilot"}} diff --git a/agent/.hive-agent-stubs-6766624/pluk/logs/hive-cxa.jsonl b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-cxa.jsonl new file mode 100644 index 0000000..8c2cafa --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-cxa.jsonl @@ -0,0 +1,38 @@ +{"v":1,"ts":"2026-10-25T12:36:14.794Z","seq":1,"pid":2401,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:36:14.814Z","seq":2,"pid":2401,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='claude' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/claude --model -- "}} +{"v":1,"ts":"2026-10-25T12:36:14.814Z","seq":3,"pid":2401,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kd"}} +{"v":1,"ts":"2026-10-25T12:36:14.814Z","seq":4,"pid":2401,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash(bootctl:*), "}} +{"v":1,"ts":"2026-10-25T12:36:14.814Z","seq":5,"pid":2401,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[KB"}} +{"v":1,"ts":"2026-10-25T12:36:14.814Z","seq":6,"pid":2401,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:36:42.105Z","seq":1,"pid":4969,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:36:42.109Z","seq":2,"pid":4969,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='goose' HIVE_MODEL='' HIVE_ACMM_LEVEL='5' HIVE_AGENT_MODE='ISSUES_AND_PRS' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_REPO='o/r' HIVE_REPOS='o/r' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive- "}} +{"v":1,"ts":"2026-10-25T12:36:42.109Z","seq":3,"pid":4969,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ka"}} +{"v":1,"ts":"2026-10-25T12:36:42.109Z","seq":4,"pid":4969,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"agent-stubs-6766624/goose run -s --text \"$(cat /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/perm/.hive-bootstrap-cxa.txt)\"\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:36:48.594Z","seq":1,"pid":5508,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:36:48.603Z","seq":2,"pid":5508,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='goose' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/goose run -s --text "}} +{"v":1,"ts":"2026-10-25T12:36:48.603Z","seq":3,"pid":5508,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":" \"$(cat /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/perm/.hive-bootstrap-cxa.txt)\"\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:36:55.112Z","seq":1,"pid":6215,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:36:55.114Z","seq":2,"pid":6215,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='claude' HIVE_MODEL='opus' HIVE_ACMM_LEVEL='5' HIVE_AGENT_MODE='ISSUES_ONLY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/claude --mo "}} +{"v":1,"ts":"2026-10-25T12:36:55.114Z","seq":3,"pid":6215,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kd"}} +{"v":1,"ts":"2026-10-25T12:36:55.114Z","seq":4,"pid":6215,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"del opus --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash( "}} +{"v":1,"ts":"2026-10-25T12:36:55.120Z","seq":5,"pid":6215,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kb"}} +{"v":1,"ts":"2026-10-25T12:36:55.120Z","seq":6,"pid":6215,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:37:01.211Z","seq":1,"pid":7026,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:37:01.239Z","seq":2,"pid":7026,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='claude' HIVE_MODEL='opus' HIVE_ACMM_LEVEL='5' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/claude --model "}} +{"v":1,"ts":"2026-10-25T12:37:01.239Z","seq":3,"pid":7026,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":" opus --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash(boo "}} +{"v":1,"ts":"2026-10-25T12:37:01.239Z","seq":4,"pid":7026,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kt"}} +{"v":1,"ts":"2026-10-25T12:37:01.239Z","seq":5,"pid":7026,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"tctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:37:07.163Z","seq":1,"pid":7730,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:37:07.165Z","seq":2,"pid":7730,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='copilot' HIVE_MODEL='auto' HIVE_ACMM_LEVEL='1' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/copilot --mod "}} +{"v":1,"ts":"2026-10-25T12:37:07.165Z","seq":3,"pid":7730,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ke"}} +{"v":1,"ts":"2026-10-25T12:37:07.167Z","seq":4,"pid":7730,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"el auto --no-auto-update --allow-all --deny-tool='github-mcp-server(create_pull_request)' --deny-tool='github-mcp-server(create_pull_request_with_copilot)' --deny-tool='github-mcp-server(merge_pull_request)' --deny-tool='github-mcp-server(create_issue)' --deny-tool='github-mcp-server(update_issue)' --deny-tool='github-mcp-server(add_issue_comment)'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:37:13.125Z","seq":1,"pid":8203,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:37:13.138Z","seq":2,"pid":8203,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='claude' HIVE_MODEL='opus' HIVE_ACMM_LEVEL='5' HIVE_AGENT_MODE='ISSUES_AND_PRS' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/claude - "}} +{"v":1,"ts":"2026-10-25T12:37:13.140Z","seq":3,"pid":8203,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K-"}} +{"v":1,"ts":"2026-10-25T12:37:13.157Z","seq":4,"pid":8203,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"-model opus --dangerously-skip-permissions --disallowed-tools 'mcp__github__merge_pull_request'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:37:19.789Z","seq":1,"pid":8813,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:37:19.792Z","seq":2,"pid":8813,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='cxa' HIVE_AGENT_DISPLAY_NAME='cxa' HIVE_BACKEND='claude' HIVE_MODEL='opus' HIVE_ACMM_LEVEL='5' HIVE_AGENT_MODE='ISSUES_AND_PRS' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='cxa' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/claude - "}} +{"v":1,"ts":"2026-10-25T12:37:19.793Z","seq":3,"pid":8813,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K-"}} +{"v":1,"ts":"2026-10-25T12:37:19.812Z","seq":4,"pid":8813,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"-model opus --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Ba "}} +{"v":1,"ts":"2026-10-25T12:37:19.813Z","seq":5,"pid":8813,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ks"}} +{"v":1,"ts":"2026-10-25T12:37:19.899Z","seq":6,"pid":8813,"session":"hive-cxa","pane":"0","source":"watch","type":"raw_output","data":{"line":"sh(bootctl:*),Bash(efibootmgr:*)' --mcp-server 'https://mcp.example'\u001b[?2004l"}} diff --git a/agent/.hive-agent-stubs-6766624/pluk/logs/hive-kdfires.jsonl b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-kdfires.jsonl new file mode 100644 index 0000000..f8b85f3 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-kdfires.jsonl @@ -0,0 +1 @@ +{"v":1,"ts":"2026-10-25T12:39:30.492Z","seq":1,"pid":25235,"session":"hive-kdfires","pane":"0","source":"watch","type":"raw_output","data":{"line":"HOME=/data/home /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/copilot"}} diff --git a/agent/.hive-agent-stubs-6766624/pluk/logs/hive-reviewer.jsonl b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-reviewer.jsonl new file mode 100644 index 0000000..29f8903 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-reviewer.jsonl @@ -0,0 +1 @@ +{"v":1,"ts":"2026-10-25T12:37:50.282Z","seq":1,"pid":11978,"session":"hive-reviewer","pane":"0","source":"watch","type":"raw_output","data":{"line":"echo 'HIVE LAUNCH FAILED: backend no-such-backend did not launch: unknown backend: no-such-backend. This backend is not a supported CLI, built-in inference backend, or configured model gateway; switch this agent to a supported backend or configure a matching model gateway.'"}} diff --git a/agent/.hive-agent-stubs-6766624/pluk/logs/hive-scanner-kicklog-test.jsonl b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-scanner-kicklog-test.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/agent/.hive-agent-stubs-6766624/pluk/logs/hive-scanner.jsonl b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-scanner.jsonl new file mode 100644 index 0000000..b9f5b73 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-scanner.jsonl @@ -0,0 +1,23 @@ +{"v":1,"ts":"2026-10-25T12:35:36.370Z","seq":1,"pid":97499,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:35:36.398Z","seq":2,"pid":97499,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[Jandan02@Andrews-MacBook-Pro scanner % \u001b[K\u001b[?2004hH\bHIVE_AGENT='scanner' HIVE_AGENT_DISPLAY_NAME='scanner' HIVE_BACKEND='bob' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='scanner' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' BOBSHELL_DEFAULT_AUTH_TYPE='api-key' HIVE_BOB_DISPLAY_NAME='scanner' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem "}} +{"v":1,"ts":"2026-10-25T12:35:36.399Z","seq":3,"pid":97499,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K'"}} +{"v":1,"ts":"2026-10-25T12:35:36.400Z","seq":4,"pid":97499,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"' sh -c 'case \"$(/Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/bob --version 2>/dev/null | sed -n 1p)\" in 1.*) exec /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/bob --accept-license --auth-method api-key --approval-mode yolo --trust ;; *) exec /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/bob chat --accept-license --auto-approve --trust ;; esac'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:35:42.618Z","seq":1,"pid":97805,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:35:42.784Z","seq":2,"pid":97805,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='scanner' HIVE_AGENT_DISPLAY_NAME='scanner' HIVE_BACKEND='bob' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='scanner' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' BOBSHELL_DEFAULT_AUTH_TYPE='api-key' HIVE_BOB_DISPLAY_NAME='scanner' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' sh -c 'case \"$( "}} +{"v":1,"ts":"2026-10-25T12:35:42.787Z","seq":3,"pid":97805,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[K/"}} +{"v":1,"ts":"2026-10-25T12:35:42.787Z","seq":4,"pid":97805,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"/Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/bob --version 2>/dev/null | sed -n 1p)\" in 1.*) exec /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/bob --accept-license --auth-method api-key --approval-mode yolo --trust ;; *) exec /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/bob chat --accept-license --auto-approve --trust ;; esac'\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:36:02.335Z","seq":1,"pid":801,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:36:02.347Z","seq":2,"pid":801,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='scanner' HIVE_AGENT_DISPLAY_NAME='scanner' HIVE_BACKEND='claude' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='scanner' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' printf 'goose is ready\\n'; cat\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:36:02.347Z","seq":3,"pid":801,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"goose is ready"}} +{"v":1,"ts":"2026-10-25T12:36:02.483Z","seq":4,"pid":801,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C"}} +{"v":1,"ts":"2026-10-25T12:36:02.484Z","seq":5,"pid":801,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:36:02.547Z","seq":6,"pid":801,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hg\bgo drain the queue from real Start\u001b[?2004l"}} +{"v":1,"ts":"2026-10-25T12:37:46.965Z","seq":1,"pid":11494,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"echo 'HIVE LAUNCH FAILED: backend no-such-backend did not launch: unknown backend: no-such-backend. This backend is not a supported CLI, built-in inference backend, or configured model gateway; switch this agent to a supported backend or configure a matching model gateway.'"}} +{"v":1,"ts":"2026-10-25T12:37:48.983Z","seq":1,"pid":11774,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"echo 'HIVE LAUNCH FAILED: backend no-such-backend did not launch: unknown backend: no-such-backend. This backend is not a supported CLI, built-in inference backend, or configured model gateway; switch this agent to a supported backend or configure a matching model gateway.'"}} +{"v":1,"ts":"2026-10-25T12:37:51.153Z","seq":1,"pid":12141,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"echo 'HIVE LAUNCH FAILED: backend no-such-backend did not launch: unknown backend: no-such-backend. This backend is not a supported CLI, built-in inference backend, or configured model gateway; switch this agent to a supported backend or configure a matching model gateway.'"}} +{"v":1,"ts":"2026-10-25T12:39:50.138Z","seq":1,"pid":27609,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:39:50.369Z","seq":2,"pid":27609,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[Jandan02@Andrews-MacBook-Pro scanner % \u001b[K\u001b[?2004hH\bHIVE_AGENT='scanner' HIVE_AGENT_DISPLAY_NAME='scanner' HIVE_BACKEND='claude' HIVE_MODEL='' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='scanner' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stu "}} +{"v":1,"ts":"2026-10-25T12:39:50.369Z","seq":3,"pid":27609,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Kb"}} +{"v":1,"ts":"2026-10-25T12:39:50.397Z","seq":4,"pid":27609,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"bs-6766624/claude --model --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Ba "}} +{"v":1,"ts":"2026-10-25T12:39:50.398Z","seq":5,"pid":27609,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ks"}} +{"v":1,"ts":"2026-10-25T12:39:51.165Z","seq":6,"pid":27609,"session":"hive-scanner","pane":"0","source":"watch","type":"raw_output","data":{"line":"sh(grubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} diff --git a/agent/.hive-agent-stubs-6766624/pluk/logs/hive-worker.jsonl b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-worker.jsonl new file mode 100644 index 0000000..b723762 --- /dev/null +++ b/agent/.hive-agent-stubs-6766624/pluk/logs/hive-worker.jsonl @@ -0,0 +1,44 @@ +{"v":1,"ts":"2026-10-25T12:38:10.441Z","seq":1,"pid":14680,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ HIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-st "}} +{"v":1,"ts":"2026-10-25T12:38:10.464Z","seq":2,"pid":14680,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"ubs-6766624/claude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostre "}} +{"v":1,"ts":"2026-10-25T12:38:10.464Z","seq":3,"pid":14680,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"e:*),Bash(grubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'"}} +{"v":1,"ts":"2026-10-25T12:38:14.288Z","seq":4,"pid":14680,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":": goose is ready"}} +{"v":1,"ts":"2026-10-25T12:38:14.382Z","seq":5,"pid":14680,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":": goose is ready"}} +{"v":1,"ts":"2026-10-25T12:38:18.597Z","seq":6,"pid":14680,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C"}} +{"v":1,"ts":"2026-10-25T12:38:18.690Z","seq":7,"pid":14680,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ \u0007after the restart"}} +{"v":1,"ts":"2026-10-25T12:38:18.694Z","seq":8,"pid":14680,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh: after: command not found"}} +{"v":1,"ts":"2026-10-25T12:38:18.720Z","seq":9,"pid":14680,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:38:18.752Z","seq":10,"pid":14680,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:38:18.811Z","seq":11,"pid":14680,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:38:27.731Z","seq":1,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh: /Users/andan02/.deno/env: No such file or directory"}} +{"v":1,"ts":"2026-10-25T12:38:27.735Z","seq":2,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[?1034hAndrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:38:27.744Z","seq":3,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ HIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-st "}} +{"v":1,"ts":"2026-10-25T12:38:27.744Z","seq":4,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"ubs-6766624/claude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostre "}} +{"v":1,"ts":"2026-10-25T12:38:27.744Z","seq":5,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"e:*),Bash(grubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'"}} +{"v":1,"ts":"2026-10-25T12:38:31.821Z","seq":6,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":": goose is ready"}} +{"v":1,"ts":"2026-10-25T12:38:31.916Z","seq":7,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":": goose is ready"}} +{"v":1,"ts":"2026-10-25T12:38:31.993Z","seq":8,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C"}} +{"v":1,"ts":"2026-10-25T12:38:32.107Z","seq":9,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ \u0007recover and continue the sweep"}} +{"v":1,"ts":"2026-10-25T12:38:32.109Z","seq":10,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh: recover: command not found"}} +{"v":1,"ts":"2026-10-25T12:38:32.181Z","seq":11,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:38:32.232Z","seq":12,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:38:32.259Z","seq":13,"pid":17507,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:38:38.218Z","seq":1,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh: /Users/andan02/.deno/env: No such file or directory"}} +{"v":1,"ts":"2026-10-25T12:38:38.330Z","seq":2,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[?1034hAndrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:38:38.375Z","seq":3,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ HIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-st "}} +{"v":1,"ts":"2026-10-25T12:38:38.405Z","seq":4,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"ubs-6766624/claude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostre "}} +{"v":1,"ts":"2026-10-25T12:38:38.411Z","seq":5,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"e:*),Bash(grubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'"}} +{"v":1,"ts":"2026-10-25T12:38:42.710Z","seq":6,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":": goose is ready"}} +{"v":1,"ts":"2026-10-25T12:38:42.710Z","seq":7,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":": goose is ready"}} +{"v":1,"ts":"2026-10-25T12:38:42.930Z","seq":8,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"^C"}} +{"v":1,"ts":"2026-10-25T12:38:43.043Z","seq":9,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ \u0007fresh context, take the next issue"}} +{"v":1,"ts":"2026-10-25T12:38:43.046Z","seq":10,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"-sh: fresh: command not found"}} +{"v":1,"ts":"2026-10-25T12:38:43.101Z","seq":11,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:38:43.159Z","seq":12,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:38:43.189Z","seq":13,"pid":19178,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"Andrews-MacBook-Pro:worker andan02$ "}} +{"v":1,"ts":"2026-10-25T12:39:44.082Z","seq":1,"pid":26836,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"^CHIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/claude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubby:*),Bash(bootctl:*),Bash(efibootmgr:*)'"}} +{"v":1,"ts":"2026-10-25T12:39:44.107Z","seq":2,"pid":26836,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m "}} +{"v":1,"ts":"2026-10-25T12:39:44.194Z","seq":3,"pid":26836,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[0m\u001b[27m\u001b[24m\u001b[JAndrews-MacBook-Pro% \u001b[K\u001b[?2004hH\bHIVE_AGENT='worker' HIVE_AGENT_DISPLAY_NAME='worker' HIVE_BACKEND='claude' HIVE_MODEL='sonnet' HIVE_ACMM_LEVEL='0' HIVE_AGENT_MODE='ADVISORY' HTTPS_PROXY='http://127.0.0.1:18443' HTTP_PROXY='http://127.0.0.1:18443' NO_PROXY='127.0.0.1,localhost,::1' HIVE_PROXY_AGENT='worker' GIT_TERMINAL_PROMPT='0' NODE_EXTRA_CA_CERTS='/data/proxy-ca.pem' HIVE_EXPLAIN_MODE='off' GIT_SSL_CAINFO='/data/proxy-ca.pem' /Users/andan02/hive-work/crux-publish/crux/agent/.hive-agent-stubs-6766624/cla "}} +{"v":1,"ts":"2026-10-25T12:39:44.198Z","seq":4,"pid":26836,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ku"}} +{"v":1,"ts":"2026-10-25T12:39:44.208Z","seq":5,"pid":26836,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"ude --model sonnet --dangerously-skip-permissions --disallowed-tools 'mcp__github__create_pull_request' --disallowed-tools 'mcp__github__create_pull_request_with_copilot' --disallowed-tools 'mcp__github__merge_pull_request' --disallowed-tools 'mcp__github__create_issue' --disallowed-tools 'mcp__github__update_issue' --disallowed-tools 'mcp__github__add_issue_comment' --disallowed-tools 'Bash(sudo:*),Bash(pkexec:*),Bash(doas:*),Bash(su:*),Bash(rpm-ostree:*),Bash(bootc:*),Bash(ostree:*),Bash(grubb "}} +{"v":1,"ts":"2026-10-25T12:39:44.209Z","seq":6,"pid":26836,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"\u001b[Ky"}} +{"v":1,"ts":"2026-10-25T12:39:44.233Z","seq":7,"pid":26836,"session":"hive-worker","pane":"0","source":"watch","type":"raw_output","data":{"line":"y:*),Bash(bootctl:*),Bash(efibootmgr:*)'\u001b[?2004l"}} diff --git a/agent/acmm_gate.go b/agent/acmm_gate.go new file mode 100644 index 0000000..f5f61a7 --- /dev/null +++ b/agent/acmm_gate.go @@ -0,0 +1,10 @@ +package agent + +import "github.com/hivecommons/crux" + +// AgentAvailableAtACMMLevel is the hard ACMM roster gate for agents whose +// existence is level-scoped, not just whose GitHub write authority is scoped. +// The gate lives in the crux root package (crux.AgentAvailableAtACMMLevel). +func AgentAvailableAtACMMLevel(name string, level int) bool { + return crux.AgentAvailableAtACMMLevel(name, level) +} diff --git a/agent/agent_bootstrap_test.go b/agent/agent_bootstrap_test.go new file mode 100644 index 0000000..1ceab61 --- /dev/null +++ b/agent/agent_bootstrap_test.go @@ -0,0 +1,476 @@ +package agent + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hivecommons/crux/config" +) + +func testManager(level int) *Manager { + return &Manager{ + agents: make(map[string]*AgentProcess), + idToName: make(map[string]string), + logger: slog.Default(), + project: ProjectContext{ + Org: "testorg", + Repos: []string{"repo"}, + ACMMLevel: level, + }, + } +} + +func containsBoot(s, substr string) bool { + return strings.Contains(s, substr) +} + +func TestBuildBootstrapPromptWithKickTemplate(t *testing.T) { + dir := t.TempDir() + policyDir := filepath.Join(dir, "policies", "agents") + os.MkdirAll(policyDir, 0755) + os.WriteFile(filepath.Join(policyDir, "custom-kick.md"), []byte("# Custom Kick"), 0644) + + m := &Manager{ + agents: make(map[string]*AgentProcess), + idToName: make(map[string]string), + logger: slog.Default(), + project: ProjectContext{ + Org: "testorg", + Repos: []string{"repo"}, + ACMMLevel: 2, + PolicyDir: policyDir, + }, + } + + agent := &AgentProcess{ + Name: "scanner", + Config: config.AgentConfig{Role: "scanner", KickTemplate: "custom-kick.md"}, + } + + got := m.buildBootstrapPrompt(agent) + if got != "" { + t.Error("boot prompt should be empty") + } +} + +func TestBuildBootstrapPromptWithPolicyFile(t *testing.T) { + dir := t.TempDir() + policyDir := filepath.Join(dir, "policies", "agents") + os.MkdirAll(policyDir, 0755) + os.WriteFile(filepath.Join(policyDir, "scanner.md"), []byte("# Scanner Policy"), 0644) + + m := &Manager{ + agents: make(map[string]*AgentProcess), + idToName: make(map[string]string), + logger: slog.Default(), + project: ProjectContext{ + Org: "testorg", + Repos: []string{"repo"}, + ACMMLevel: 2, + PolicyDir: policyDir, + }, + } + + agent := &AgentProcess{ + Name: "scanner", + Config: config.AgentConfig{Role: "scanner"}, + } + + got := m.buildBootstrapPrompt(agent) + if got != "" { + t.Error("boot prompt should be empty") + } + // boot prompt is now empty — agents get kicked by governor +} + +func TestBuildBootstrapPromptQuality(t *testing.T) { + m := testManager(3) + agent := &AgentProcess{ + Name: "quality", + Config: config.AgentConfig{Role: "quality"}, + } + + got := m.buildBootstrapPrompt(agent) + if got != "" { + t.Error("boot prompt should be empty") + } +} + +func TestBuildBootstrapPromptWithACMMFragments(t *testing.T) { + dir := t.TempDir() + policyDir := filepath.Join(dir, "policies", "agents") + acmmDir := filepath.Join(dir, "policies", "acmm") + os.MkdirAll(policyDir, 0755) + os.MkdirAll(acmmDir, 0755) + os.WriteFile(filepath.Join(acmmDir, "base.md"), []byte("# Base ACMM"), 0644) + os.WriteFile(filepath.Join(acmmDir, "l2.md"), []byte("# Level 2"), 0644) + + m := &Manager{ + agents: make(map[string]*AgentProcess), + idToName: make(map[string]string), + logger: slog.Default(), + project: ProjectContext{ + Org: "testorg", + Repos: []string{"repo"}, + ACMMLevel: 2, + PolicyDir: policyDir, + }, + } + + agent := &AgentProcess{ + Name: "scanner", + Config: config.AgentConfig{Role: "scanner"}, + } + + got := m.buildBootstrapPrompt(agent) + if got != "" { + t.Error("boot prompt should be empty") + } +} + +func TestBuildBootstrapPromptWithACMMFound(t *testing.T) { + dir := t.TempDir() + policyDir := filepath.Join(dir, "policies", "agents") + acmmDir := filepath.Join(dir, "policies", "examples", "acmm") + os.MkdirAll(policyDir, 0755) + os.MkdirAll(acmmDir, 0755) + os.WriteFile(filepath.Join(acmmDir, "base.md"), []byte("# Base"), 0644) + os.WriteFile(filepath.Join(acmmDir, "l2.md"), []byte("# L2"), 0644) + + m := &Manager{ + agents: make(map[string]*AgentProcess), + idToName: make(map[string]string), + logger: slog.Default(), + project: ProjectContext{ + Org: "testorg", + Repos: []string{"repo"}, + ACMMLevel: 2, + PolicyDir: policyDir, + }, + } + + agent := &AgentProcess{Name: "scanner", Config: config.AgentConfig{Role: "scanner"}} + got := m.buildBootstrapPrompt(agent) + // boot prompt is now empty — agents get kicked by governor + _ = got +} + +func TestNewManager(t *testing.T) { + agents := map[string]config.AgentConfig{ + "scanner": {ID: "scan-001", Role: "scanner", Backend: "claude", Model: "sonnet"}, + "quality": {ID: "qual-002", Role: "quality", Backend: "claude", Model: "opus"}, + } + project := ProjectContext{ + Org: "testorg", + Repos: []string{"repo1"}, + ACMMLevel: 3, + PRsAllowed: true, + } + + m := NewManager(agents, slog.Default(), project) + if m == nil { + t.Fatal("NewManager returned nil") + } + if len(m.agents) != 2 { + t.Errorf("expected 2 agents, got %d", len(m.agents)) + } + if m.agents["scanner"] == nil { + t.Error("scanner should be present") + } + if m.agents["scanner"].ID != "scan-001" { + t.Errorf("scanner ID = %q", m.agents["scanner"].ID) + } + if m.idToName["scan-001"] != "scanner" { + t.Error("idToName should map scan-001 to scanner") + } + if m.project.ACMMLevel != 3 { + t.Errorf("ACMMLevel = %d", m.project.ACMMLevel) + } +} + +func TestNewManagerEmpty(t *testing.T) { + m := NewManager(nil, slog.Default(), ProjectContext{}) + if m == nil { + t.Fatal("nil agents should still create manager") + } + if len(m.agents) != 0 { + t.Error("should have 0 agents") + } +} + +func TestLogOutputSignals(t *testing.T) { + m := &Manager{logger: slog.Default()} + + m.logOutputSignals("scanner", "Created 3 new issues for the project") + m.logOutputSignals("scanner", "FAIL: TestSomething") + m.logOutputSignals("scanner", "coverage report: 85.5 percent") + m.logOutputSignals("scanner", "nothing interesting here") +} + +func TestLogOutputSignalsLong(t *testing.T) { + m := &Manager{logger: slog.Default()} + long := "created file " + strings.Repeat("x", 300) + m.logOutputSignals("scanner", long) +} + +func TestIsBufferNoisePatterns(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"╭─╮ Some banner", true}, + {"╰─╯", true}, + {"█ ▘▝ █", true}, + {"▔▔▔▔▔", true}, + {"Copilot v1.0.0", true}, + {"Check for mistakes in this code", true}, + {"/ commands available", true}, + {"? help for info", true}, + {"@ files", true}, + {"Some text with esc cancel here", true}, + {"─────────────────", true}, + {"━━━━━━━━━━━━━━━", true}, + {"/data/agents/scanner", true}, + {"Processing file.go", false}, + {"Created 5 issues", false}, + {"", true}, + } + for _, tt := range tests { + got := isBufferNoise(tt.input) + if got != tt.want { + t.Errorf("isBufferNoise(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestIsBufferNoiseAllPatterns(t *testing.T) { + noiseInputs := []string{ + "● Tip: Use /help for commands", + "└ scanning files...", + "↑/↓ to navigate history", + "copilot-instructions.md found during /init", + "Do you trust the files in this folder?", + "› Yes, I trust the authors", + "› No (Esc)", + "● Folder /data/agents/scanner is now trusted", + "✗ Model gpt-5 not available, using fallback", + } + for _, input := range noiseInputs { + if !isBufferNoise(input) { + t.Errorf("isBufferNoise(%q) = false, want true", input) + } + } +} + +func TestIsCLIChromeMorePatterns(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"# issues to review", true}, + {"Claude 3.5 Sonnet", false}, + {"model: claude-3-opus", false}, + } + for _, tt := range tests { + got := isCLIChrome(tt.input) + if got != tt.want { + t.Errorf("isCLIChrome(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestAgentEnvPairsWithEnvVars(t *testing.T) { + t.Setenv("HIVE_SHA", "abc123") + t.Setenv("HIVE_ADVISORY_ISSUE", "42") + + m := testManager(2) + m.copilotAuthToken = "ghp_copilot_test" + + agent := &AgentProcess{ + Name: "scanner", + UID: 1001, + Config: config.AgentConfig{Role: "scanner", Backend: "claude", Model: "sonnet", BeadsDir: "/data/beads/scanner"}, + } + + pairs := m.agentEnvPairs(agent) + + foundSHA := false + foundAdvisory := false + foundCopilot := false + foundHome := false + foundBD := false + for _, p := range pairs { + switch p.Key { + case "HIVE_SHA": + foundSHA = true + if p.Value != "abc123" { + t.Errorf("HIVE_SHA = %q", p.Value) + } + case "HIVE_ADVISORY_ISSUE": + foundAdvisory = true + case "COPILOT_GITHUB_TOKEN": + foundCopilot = true + if !p.Secret { + t.Error("COPILOT_GITHUB_TOKEN should be secret") + } + case "HOME": + foundHome = true + case "BD_DIR": + foundBD = true + } + } + if !foundSHA { + t.Error("should include HIVE_SHA") + } + if !foundAdvisory { + t.Error("should include HIVE_ADVISORY_ISSUE") + } + if !foundCopilot { + t.Error("should include COPILOT_GITHUB_TOKEN") + } + if !foundHome { + t.Error("UID > 0 should include HOME") + } + if !foundBD { + t.Error("should include BD_DIR") + } +} + +func TestBuildEnvPrefixSkipsSecrets(t *testing.T) { + m := testManager(2) + m.copilotAuthToken = "ghp_secret" + + agent := &AgentProcess{ + Name: "scanner", + Config: config.AgentConfig{Role: "scanner", Backend: "claude", Model: "sonnet"}, + } + + got := m.buildEnvPrefix(agent) + if containsBoot(got, "ghp_secret") { + t.Error("buildEnvPrefix should NOT include secret env vars") + } +} + +func TestBuildBootstrapPromptEmptyPolicyDir(t *testing.T) { + m := &Manager{ + agents: make(map[string]*AgentProcess), + idToName: make(map[string]string), + logger: slog.Default(), + project: ProjectContext{ + Org: "testorg", + Repos: []string{"repo"}, + ACMMLevel: 2, + PolicyDir: "", + }, + } + + agent := &AgentProcess{Name: "scanner", Config: config.AgentConfig{Role: "scanner"}} + got := m.buildBootstrapPrompt(agent) + if got != "" { + t.Error("should produce bootstrap prompt even without policy dir") + } +} + +func TestConfigHasTokensFile(t *testing.T) { + got := configHasTokens() + _ = got +} + +func TestSyncModeFilesWithPausedAgent(t *testing.T) { + m := &Manager{ + agents: map[string]*AgentProcess{ + "scanner": {Name: "scanner", State: StateRunning, Config: config.AgentConfig{Role: "scanner"}}, + "quality": {Name: "quality", State: StatePaused, Paused: true, Config: config.AgentConfig{Role: "quality"}}, + }, + logger: slog.Default(), + project: ProjectContext{ACMMLevel: 3}, + } + + m.SyncModeFiles(3) +} + +func TestSyncModeFilesWithModeOverride(t *testing.T) { + m := &Manager{ + agents: map[string]*AgentProcess{ + "scanner": {Name: "scanner", State: StateRunning, Config: config.AgentConfig{Role: "scanner", Mode: "ISSUES_AND_PRS"}}, + }, + logger: slog.Default(), + project: ProjectContext{ACMMLevel: 2}, + } + + m.SyncModeFiles(2) +} + +func TestSyncModeFilesLevel3(t *testing.T) { + dir := t.TempDir() + m := &Manager{ + agents: map[string]*AgentProcess{ + "scanner": {Name: "scanner", State: StateRunning, Config: config.AgentConfig{Role: "scanner"}}, + "quality": {Name: "quality", State: StateRunning, Config: config.AgentConfig{Role: "quality"}}, + }, + logger: slog.Default(), + workDir: dir, + project: ProjectContext{ACMMLevel: 3}, + } + + m.SyncModeFiles(3) + + // Check mode files were written + scannerMode, _ := os.ReadFile(filepath.Join(agentStateDir, ".hive-mode-scanner")) + _ = scannerMode +} + +func TestSyncModeFilesLevel6(t *testing.T) { + m := &Manager{ + agents: map[string]*AgentProcess{ + "scanner": {Name: "scanner", State: StateRunning, Config: config.AgentConfig{Role: "scanner"}}, + }, + logger: slog.Default(), + project: ProjectContext{ACMMLevel: 6}, + } + + m.SyncModeFiles(6) +} + +func TestAddAgentWithID(t *testing.T) { + m := &Manager{ + agents: make(map[string]*AgentProcess), + idToName: make(map[string]string), + logger: slog.Default(), + workDir: "/tmp/hive-test", + } + + m.AddAgent("test-agent", config.AgentConfig{ + ID: "agent-001", + Role: "scanner", + Backend: "claude", + Model: "sonnet", + DisplayName: "Test Agent", + }) + + if _, ok := m.agents["test-agent"]; !ok { + t.Error("agent should be added") + } + if m.idToName["agent-001"] != "test-agent" { + t.Error("ID mapping should be set") + } +} + +func TestRemoveAgentExisting(t *testing.T) { + m := &Manager{ + agents: map[string]*AgentProcess{ + "scanner": {Name: "scanner"}, + "quality": {Name: "quality"}, + }, + idToName: make(map[string]string), + logger: slog.Default(), + } + + m.RemoveAgent("scanner") + if _, ok := m.agents["scanner"]; ok { + t.Error("scanner should be removed") + } +} diff --git a/agent/agent_capabilities_test.go b/agent/agent_capabilities_test.go new file mode 100644 index 0000000..7c960a7 --- /dev/null +++ b/agent/agent_capabilities_test.go @@ -0,0 +1,69 @@ +package agent + +import ( + "testing" + + "github.com/hivecommons/crux/config" +) + +// AgentCapabilities must return exactly the mode's CanCreateIssues/CanPush/ +// CanMerge gates — the same gates AuthorizePROpen/AuthorizeMerge enforce — so a +// hub capability badge can never claim a capability the relay would refuse. +func TestAgentCapabilities_TracksModeGates(t *testing.T) { + m := testManager(6) + m.agents["advisor"] = &AgentProcess{Name: "advisor", Config: config.AgentConfig{Mode: "ISSUES_ONLY"}} + m.agents["writer"] = &AgentProcess{Name: "writer", Config: config.AgentConfig{Mode: "ISSUES_AND_PRS"}} + m.agents["merger"] = &AgentProcess{Name: "merger", Config: config.AgentConfig{Mode: "ISSUES_PRS_MERGE"}} + + cases := []struct { + name string + wantIssue, wantPR, wantMerge bool + }{ + {"advisor", true, false, false}, + {"writer", true, true, false}, + {"merger", true, true, true}, + } + for _, tc := range cases { + issue, pr, merge, ok := m.AgentCapabilities(tc.name) + if !ok { + t.Fatalf("%s: ok=false, want true", tc.name) + } + if issue != tc.wantIssue || pr != tc.wantPR || merge != tc.wantMerge { + t.Errorf("%s: got (issue=%v pr=%v merge=%v), want (%v %v %v)", + tc.name, issue, pr, merge, tc.wantIssue, tc.wantPR, tc.wantMerge) + } + + // The PR/merge results must agree with the actual authorization gates. + prAllowed := m.AuthorizePROpen(tc.name, 0) == nil + mergeAllowed := m.AuthorizeMerge(tc.name, 0, "acme/repo") == nil + if pr != prAllowed { + t.Errorf("%s: CanOpenPR=%v but AuthorizePROpen allowed=%v", tc.name, pr, prAllowed) + } + if merge != mergeAllowed { + t.Errorf("%s: CanMerge=%v but AuthorizeMerge allowed=%v", tc.name, merge, mergeAllowed) + } + } +} + +func TestAgentCapabilities_UnknownAgent(t *testing.T) { + m := testManager(6) + if _, _, _, ok := m.AgentCapabilities("ghost"); ok { + t.Error("unknown agent must return ok=false") + } +} + +func TestEffectiveBackend_HonorsOverride(t *testing.T) { + m := testManager(5) + m.agents["a"] = &AgentProcess{Name: "a", Config: config.AgentConfig{Backend: "claude"}} + m.agents["b"] = &AgentProcess{Name: "b", Config: config.AgentConfig{Backend: "claude"}, BackendOverride: "copilot"} + + if b, ok := m.EffectiveBackend("a"); !ok || b != "claude" { + t.Errorf("a: got (%q,%v), want (claude,true)", b, ok) + } + if b, ok := m.EffectiveBackend("b"); !ok || b != "copilot" { + t.Errorf("b: override must win; got (%q,%v), want (copilot,true)", b, ok) + } + if _, ok := m.EffectiveBackend("ghost"); ok { + t.Error("unknown agent must return ok=false") + } +} diff --git a/agent/agent_repo_scope_test.go b/agent/agent_repo_scope_test.go new file mode 100644 index 0000000..75be8ce --- /dev/null +++ b/agent/agent_repo_scope_test.go @@ -0,0 +1,131 @@ +package agent + +import ( + "testing" + + "github.com/hivecommons/crux/config" +) + +func scopedProject(scopes map[string][]string) ProjectContext { + p := ProjectContext{ + Org: "testorg", + Repos: []string{"console", "dashboard", "docs"}, + PrimaryRepoName: "console", + ACMMLevel: 4, + PRsAllowed: true, + } + if len(scopes) == 0 { + return p + } + p.AgentRepos = func(agent string) []string { + scope, ok := scopes[agent] + if !ok { + return p.Repos + } + out := make([]string, 0, len(p.Repos)) + for _, r := range p.Repos { + for _, s := range scope { + if r == s { + out = append(out, r) + } + } + } + return out + } + p.AgentPrimaryRepo = func(agent string) string { + if repos := p.AgentRepos(agent); len(repos) > 0 { + return repos[0] + } + return "" + } + return p +} + +func TestProjectContext_ReposForFallsBackWhenUnwired(t *testing.T) { + p := scopedProject(nil) + if got := p.ReposFor("anything"); len(got) != 3 { + t.Fatalf("ReposFor with no scope function = %v, want the hive list", got) + } + if got := p.PrimaryRepoFor("anything"); got != "console" { + t.Errorf("PrimaryRepoFor with no scope function = %q, want console", got) + } +} + +func TestProjectContext_ReposForScopedAgent(t *testing.T) { + p := scopedProject(map[string][]string{"schema": {"dashboard"}}) + + if got := p.ReposFor("schema"); len(got) != 1 || got[0] != "dashboard" { + t.Errorf("ReposFor(schema) = %v, want [dashboard]", got) + } + if got := p.ReposFor("scanner"); len(got) != 3 { + t.Errorf("ReposFor(unscoped) = %v, want all three", got) + } + // $HIVE_REPO must point a specialist at a repo it may actually write to. + if got := p.PrimaryRepoFor("schema"); got != "dashboard" { + t.Errorf("PrimaryRepoFor(schema) = %q, want dashboard", got) + } + if got := p.PrimaryRepoFor("scanner"); got != "console" { + t.Errorf("PrimaryRepoFor(unscoped) = %q, want console", got) + } + // The hive's own repo list is untouched: the scope narrows a view, not the + // hive. + if len(p.Repos) != 3 { + t.Errorf("Repos was mutated to %v", p.Repos) + } +} + +func TestAgentEnvPairs_QualifiesScopedRepoEnv(t *testing.T) { + tests := []struct { + name string + repos []string + wantRepo string + wantRepos string + }{ + { + name: "cross-org primary is not double-qualified", + repos: []string{"laredo/cuga-agent"}, + wantRepo: "laredo/cuga-agent", + wantRepos: "laredo/cuga-agent", + }, + { + name: "same-org short name is qualified", + repos: []string{"console"}, + wantRepo: "testorg/console", + wantRepos: "testorg/console", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := ProjectContext{ + Org: "testorg", + Repos: tt.repos, + PrimaryRepoName: "dashboard", + ACMMLevel: 4, + AgentRepos: func(string) []string { return tt.repos }, + AgentPrimaryRepo: func(string) string { + return tt.repos[0] + }, + } + m := NewManager(map[string]config.AgentConfig{ + "schema": {Backend: "claude", Model: "claude-sonnet-5"}, + }, discardLogger(), p) + m.mu.RLock() + agent := m.agents["schema"] + m.mu.RUnlock() + if agent == nil { + t.Fatal("fixture agent not registered") + } + + env := map[string]string{} + for _, pair := range m.agentEnvPairs(agent) { + env[pair.Key] = pair.Value + } + if got := env["HIVE_REPO"]; got != tt.wantRepo { + t.Errorf("HIVE_REPO = %q, want %q", got, tt.wantRepo) + } + if got := env["HIVE_REPOS"]; got != tt.wantRepos { + t.Errorf("HIVE_REPOS = %q, want %q", got, tt.wantRepos) + } + }) + } +} diff --git a/agent/agent_state_file_test.go b/agent/agent_state_file_test.go new file mode 100644 index 0000000..dee81d5 --- /dev/null +++ b/agent/agent_state_file_test.go @@ -0,0 +1,182 @@ +package agent + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + "testing" +) + +// N15 (CWE-367/732/20): per-agent control state in the pod-shared /tmp must be +// owner-WRITE-only and must not follow symlinks. It must remain world-readable, +// because its consumers run as the per-agent UID, not the hive uid — see +// agentStateFileMode and #3679/#3881/#3882. +// +// Two files matter, both at paths derived from the agent NAME (guessable): +// +// - /tmp/.hive-mode- — bin/gh-wrapper.sh takes its enforcement mode +// from this file IN PREFERENCE to the trustworthy env var, so planting it +// un-gates the victim agent (that half is the N15A fail-closed fix). +// - /tmp/.hive-bootstrap-.txt — goose is launched with the contents as +// its first instruction, so planting it injects an attacker-chosen prompt +// that runs with the victim's credentials at message zero. +// +// Both were written with a plain os.WriteFile at 0o644, symlink-following +// (os.WriteFile passes no O_NOFOLLOW), so a pre-planted link redirected the +// hive's own write to any file the process could reach. The 0644 itself was +// not the hole: /tmp is sticky, so an agent UID cannot replace a hive-owned +// file there, and world-read is REQUIRED — gh-wrapper.sh, git-credential-hive.sh +// and goose's `--text "$(cat ...)"` all read these files as the agent UID. The +// original N15 fix tightened to 0600 and locked every agent out of gh and git +// push (#3679/#3881); the mode is now 0644 with the symlink hardening kept. +// +// NOTE on scope: an earlier reading of this called the bootstrap path arbitrary +// CODE execution, on the theory that a nested $(...) inside the file would be +// expanded because the launch string is run by a shell. That is wrong — POSIX +// shells expand command substitution once and do not re-scan the result, so the +// content reaches goose as literal text. The finding is prompt injection, which +// these file permissions are the correct fix for. + +// TestWriteAgentStateFileIsOwnerWriteWorldRead pins the mode: only the hive uid +// may write (no group/other write bits — that is what would let one agent steer +// another), and everyone may read (the per-agent UID that consumes the file is +// not the hive uid). The write must be exact regardless of the caller's umask. +func TestWriteAgentStateFileIsOwnerWriteWorldRead(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".hive-mode-victim") + + restore := syscall.Umask(0o077) // the restrictive umask that produced 0600 in prod (#3882) + defer syscall.Umask(restore) + + if err := writeAgentStateFile(path, []byte("ISSUES_ONLY")); err != nil { + t.Fatalf("writeAgentStateFile: %v", err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := fi.Mode().Perm(); got != 0o644 { + t.Errorf("mode = %o, want 644 — group/world WRITE lets one agent steer another, "+ + "but the per-agent UID must be able to READ its own mode/bootstrap file", got) + } + if b, err := os.ReadFile(path); err != nil || string(b) != "ISSUES_ONLY" { + t.Errorf("content = %q err=%v, want ISSUES_ONLY", b, err) + } +} + +// TestWriteAgentStateFileRepairsExistingMode covers the upgrade path in both +// directions: a file left 0600 by the #3172 build (agent-unreadable) must be +// widened, and a file left group/world-writable must be tightened. O_CREATE +// applies its mode only when creating, so without the explicit Chmod the old +// mode would survive every rewrite. +func TestWriteAgentStateFileRepairsExistingMode(t *testing.T) { + for _, seed := range []os.FileMode{0o600, 0o666} { + dir := t.TempDir() + path := filepath.Join(dir, ".hive-mode-legacy") + if err := os.WriteFile(path, []byte("old"), seed); err != nil { + t.Fatalf("seed: %v", err) + } + if err := os.Chmod(path, seed); err != nil { + t.Fatalf("seed chmod: %v", err) + } + + if err := writeAgentStateFile(path, []byte("ADVISORY")); err != nil { + t.Fatalf("writeAgentStateFile: %v", err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := fi.Mode().Perm(); got != 0o644 { + t.Errorf("seed %o: mode = %o, want 644 — a pre-existing file must be repaired on rewrite", seed, got) + } + } +} + +// TestWriteAgentStateFileRefusesSymlink is the core of N15: a symlink planted at +// the predictable path must make the write FAIL, not silently redirect it. +func TestWriteAgentStateFileRefusesSymlink(t *testing.T) { + dir := t.TempDir() + victim := filepath.Join(dir, "victim-target") + if err := os.WriteFile(victim, []byte("ORIGINAL"), 0o600); err != nil { + t.Fatalf("seed victim: %v", err) + } + + link := filepath.Join(dir, ".hive-mode-planted") + if err := os.Symlink(victim, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + err := writeAgentStateFile(link, []byte("ISSUES_PRS_MERGE")) + if err == nil { + t.Fatal("writing through a planted symlink SUCCEEDED — O_NOFOLLOW is not in effect; " + + "an attacker can redirect the hive's own write to an arbitrary file") + } + + // The symlink target must be untouched. + b, readErr := os.ReadFile(victim) + if readErr != nil { + t.Fatalf("read victim: %v", readErr) + } + if string(b) != "ORIGINAL" { + t.Fatalf("symlink target was CLOBBERED (now %q) — the write followed the link", b) + } +} + +// TestWriteAgentStateFileChmodsViaDescriptor pins the #3175 fix at source +// level: the mode-tightening step must go through the open descriptor +// (f.Chmod), never through the pathname after Close. O_NOFOLLOW only proves +// the path is not a symlink at OPEN time; a path-based os.Chmod issued in the +// close→chmod window can be redirected by swapping the pathname for a symlink +// in shared /tmp. The runtime tests above are the positive control that the +// tightening still happens; this asserts HOW it happens, which no runtime +// probe can observe reliably (the race window is microseconds). +func TestWriteAgentStateFileChmodsViaDescriptor(t *testing.T) { + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + body, err := os.ReadFile(filepath.Join(filepath.Dir(file), "manager_homes.go")) + if err != nil { + t.Fatalf("read manager.go: %v", err) + } + text := string(body) + start := strings.Index(text, "func writeAgentStateFile(") + if start < 0 { + t.Fatal("writeAgentStateFile not found in manager.go") + } + end := strings.Index(text[start:], "\n}\n") + if end < 0 { + t.Fatal("could not delimit writeAgentStateFile body") + } + fn := text[start : start+end] + if !strings.Contains(fn, "f.Chmod(agentStateFileMode)") { + t.Fatal("writeAgentStateFile must tighten the mode via the open descriptor (f.Chmod) — " + + "see #3175: a path-based chmod after Close can be symlink-swapped") + } + if strings.Contains(fn, "os.Chmod(") { + t.Fatal("writeAgentStateFile must not use path-based os.Chmod — " + + "the close→chmod window is symlink-swappable in shared /tmp (#3175)") + } +} + +// TestWriteAgentStateFileOverwritesRegularFile guards against over-correction: +// these files are legitimately rewritten on every mode change and relaunch, so +// the hardening must not be O_EXCL. +func TestWriteAgentStateFileOverwritesRegularFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".hive-mode-rewrite") + + if err := writeAgentStateFile(path, []byte("ISSUES_ONLY")); err != nil { + t.Fatalf("first write: %v", err) + } + if err := writeAgentStateFile(path, []byte("NO_GITHUB")); err != nil { + t.Fatalf("rewrite must succeed (mode changes rewrite this file): %v", err) + } + b, err := os.ReadFile(path) + if err != nil || string(b) != "NO_GITHUB" { + t.Fatalf("content = %q err=%v, want NO_GITHUB (truncating rewrite)", b, err) + } +} diff --git a/agent/agent_unit_test.go b/agent/agent_unit_test.go new file mode 100644 index 0000000..185a660 --- /dev/null +++ b/agent/agent_unit_test.go @@ -0,0 +1,740 @@ +package agent + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "github.com/hivecommons/crux/config" +) + +func TestSetAndGetACMMLevel(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{ACMMLevel: 2}) + + if m.GetACMMLevel() != 2 { + t.Errorf("initial level = %d, want 2", m.GetACMMLevel()) + } + m.SetACMMLevel(5) + if m.GetACMMLevel() != 5 { + t.Errorf("after set level = %d, want 5", m.GetACMMLevel()) + } +} + +func TestOperabilityAgentsRequireACMML5(t *testing.T) { + for _, name := range []string{"telemetry", "operations"} { + for level := 0; level < 5; level++ { + if AgentAvailableAtACMMLevel(name, level) { + t.Fatalf("%s should be unavailable at L%d", name, level) + } + } + if !AgentAvailableAtACMMLevel(name, 5) || !AgentAvailableAtACMMLevel(name, 6) { + t.Fatalf("%s should be available at L5/L6", name) + } + } + if !AgentAvailableAtACMMLevel("scanner", 1) { + t.Fatal("non-operability agents must not be affected by the L5 gate") + } +} + +func TestManagerDoesNotInstantiateOperabilityAgentsBelowL5(t *testing.T) { + cfgs := map[string]config.AgentConfig{ + "scanner": {Backend: "copilot"}, + "telemetry": {Backend: "copilot"}, + "operations": {Backend: "copilot"}, + } + m := NewManager(cfgs, slog.Default(), ProjectContext{ACMMLevel: 3}) + + if _, ok := m.agents["telemetry"]; ok { + t.Fatal("telemetry should not be instantiated below L5") + } + if _, ok := m.agents["operations"]; ok { + t.Fatal("operations should not be instantiated below L5") + } + if _, ok := m.agents["scanner"]; !ok { + t.Fatal("scanner should still be instantiated below L5") + } + + m.AddAgent("telemetry", config.AgentConfig{Backend: "copilot"}) + if _, ok := m.agents["telemetry"]; ok { + t.Fatal("AddAgent should respect the L5 operability gate") + } + + m.SetACMMLevel(5) + m.AddAgent("telemetry", config.AgentConfig{Backend: "copilot"}) + if _, ok := m.agents["telemetry"]; !ok { + t.Fatal("telemetry should be addable at L5") + } + + m.SetACMMLevel(4) + if _, ok := m.agents["telemetry"]; ok { + t.Fatal("downgrading below L5 should remove instantiated telemetry") + } +} + +func TestReconcileAgentsTreatsOperabilityAgentsAsAbsentBelowL5(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "copilot"}, + "telemetry": {Backend: "copilot"}, + }, slog.Default(), ProjectContext{ACMMLevel: 5}) + + if _, ok := m.agents["telemetry"]; !ok { + t.Fatal("telemetry should start present at L5") + } + m.SetACMMLevel(3) + m.ReconcileAgents(map[string]config.AgentConfig{ + "scanner": {Backend: "copilot"}, + "telemetry": {Backend: "copilot"}, + }) + if _, ok := m.agents["telemetry"]; ok { + t.Fatal("reconcile below L5 should retire stale telemetry even if config still lists it") + } +} + +func TestResolveAgentByName(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {ID: "scan-001", Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + if m.ResolveAgent("scanner") != "scanner" { + t.Error("should resolve by name") + } +} + +func TestResolveAgentByID(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {ID: "scan-001", Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + if m.ResolveAgent("scan-001") != "scanner" { + t.Error("should resolve by ID") + } +} + +func TestResolveAgentUnknown(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + if m.ResolveAgent("nonexistent") != "nonexistent" { + t.Error("unknown should return input unchanged") + } +} + +func TestAgentModeAdvisory(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude", Role: "scanner"}, + }, slog.Default(), ProjectContext{ACMMLevel: 2}) + + m.mu.RLock() + agent := m.agents["scanner"] + m.mu.RUnlock() + + mode := m.agentMode(agent) + if mode != ModeAdvisory { + t.Errorf("L2 scanner should be ADVISORY, got %s", mode) + } +} + +func TestAgentModeIssuesOnly(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude", Role: "scanner"}, + }, slog.Default(), ProjectContext{ACMMLevel: 4}) + + m.mu.RLock() + agent := m.agents["scanner"] + m.mu.RUnlock() + + mode := m.agentMode(agent) + if mode != ModeIssuesOnly { + t.Errorf("L4 scanner should be ISSUES_ONLY, got %s", mode) + } +} + +func TestAgentModeOverride(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude", Role: "scanner", Mode: "ISSUES_AND_PRS"}, + }, slog.Default(), ProjectContext{ACMMLevel: 2}) + + m.mu.RLock() + agent := m.agents["scanner"] + m.mu.RUnlock() + + mode := m.agentMode(agent) + if mode != ModeIssuesAndPRs { + t.Errorf("mode override should be ISSUES_AND_PRS, got %s", mode) + } +} + +func TestPaneLinesEmpty(t *testing.T) { + agent := &AgentProcess{ + Name: "scanner", + } + lines := agent.PaneLines(10) + if len(lines) != 0 { + t.Errorf("empty pane should return 0 lines, got %d", len(lines)) + } +} + +func TestPaneLinesWithData(t *testing.T) { + agent := &AgentProcess{ + Name: "scanner", + lastPaneCapture: []string{"line1", "line2", "line3", "line4", "line5"}, + } + lines := agent.PaneLines(3) + if len(lines) > 3 { + t.Errorf("should return at most 3 lines, got %d", len(lines)) + } +} + +func TestDeduplicateBlocks(t *testing.T) { + lines := []string{"a", "b", "c", "a", "b", "c", "d"} + result := DeduplicateBlocks(lines) + if len(result) >= len(lines) { + t.Logf("dedup: %d -> %d", len(lines), len(result)) + } +} + +func TestFilteredPaneLinesViaGetOutput(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + output, err := m.GetOutput("scanner", 10) + if err != nil { + t.Fatal(err) + } + _ = output +} + +func TestDiffNewLines(t *testing.T) { + old := []string{"line1", "line2"} + new := []string{"line1", "line2", "line3", "line4"} + diff := diffNewLines(old, new) + if len(diff) != 2 { + t.Errorf("expected 2 new lines, got %d", len(diff)) + } +} + +func TestDiffNewLinesEmpty(t *testing.T) { + diff := diffNewLines(nil, []string{"a", "b"}) + if len(diff) != 2 { + t.Errorf("nil old should return all new, got %d", len(diff)) + } +} + +func TestDiffNewLinesSame(t *testing.T) { + lines := []string{"a", "b", "c"} + diff := diffNewLines(lines, lines) + if len(diff) != 0 { + t.Errorf("same lines should return 0, got %d", len(diff)) + } +} + +func TestIsVisualNoise(t *testing.T) { + noisy := []string{ + "───────────────", + "━━━━━━━━━━━━━━━", + "", + " ", + "/data/agents/scanner", + } + for _, line := range noisy { + if !isVisualNoise(line) { + t.Errorf("should be noise: %q", line) + } + } + if isVisualNoise("real output here") { + t.Error("real output should not be noise") + } +} + +func TestIsBufferNoise(t *testing.T) { + // Test that empty/whitespace is noise + if !isBufferNoise("") { + t.Error("empty string should be buffer noise") + } + if !isBufferNoise(" ") { + t.Error("whitespace should be buffer noise") + } + if isBufferNoise("real meaningful output") { + t.Error("real output should not be buffer noise") + } +} + +func TestBackendBinaryReturnsPath(t *testing.T) { + for _, backend := range []string{"claude", "copilot", "goose", "gemini"} { + got, _ := backendBinary(backend) + if got == "" { + t.Errorf("backendBinary(%q) returned empty", backend) + } + } + // Unknown backend + got, _ := backendBinary("unknown") + _ = got // may return empty or fallback +} + +func TestPaneShowsLoginPromptUnit(t *testing.T) { + normalLines := []string{ + "normal output", + "no login needed", + } + if paneShowsLoginPrompt(normalLines) { + t.Error("should not detect login in normal output") + } + // Empty pane should not show login + if paneShowsLoginPrompt(nil) { + t.Error("nil pane should not show login") + } +} + +func TestPaneShowsFatalNetworkErrorUnit(t *testing.T) { + tlsError := []string{ + "Copilot v1.0.63 uses AI.", + "Error auto updating: Failed to fetch latest release: HttpError: request failed: error sending request for url", + "(https://api.github.com/repos/github/copilot-cli/releases/latest): client error (Connect): invalid peer certificate: BadSignature", + "❯", + "/ commands · ? help", + } + if !paneShowsFatalNetworkError(tlsError) { + t.Error("should detect BadSignature TLS error") + } + + fetchFailed := []string{ + "Error during sign-in: TypeError: fetch failed", + "Press any key to retry", + } + if !paneShowsFatalNetworkError(fetchFailed) { + t.Error("should detect fetch failed error") + } + + normalLines := []string{ + "normal output", + "❯", + "/ commands · ? help", + } + if paneShowsFatalNetworkError(normalLines) { + t.Error("should not detect error in normal output") + } + + if paneShowsFatalNetworkError(nil) { + t.Error("nil pane should not show error") + } +} + +// TestPaneShowsCopilotModelFallbackUnit covers #9927: the Copilot CLI's +// startup banner when --model names an id the pinned CLI rejects, and the +// substituted model it silently ran instead. +func TestPaneShowsCopilotModelFallbackUnit(t *testing.T) { + banner := []string{ + "Copilot v1.0.88 uses AI.", + `Model "claude-sonnet-5-5" from --model flag is not available. Using "claude-sonnet-5" instead.`, + "❯", + } + requested, running, ok := paneShowsCopilotModelFallback(banner) + if !ok { + t.Fatal("should detect the model fallback banner") + } + if requested != "claude-sonnet-5-5" { + t.Errorf("requested model = %q, want claude-sonnet-5-5", requested) + } + if running != "claude-sonnet-5" { + t.Errorf("running model = %q, want claude-sonnet-5", running) + } + + normalLines := []string{ + "normal output", + "❯", + "/ commands · ? help", + } + if _, _, ok := paneShowsCopilotModelFallback(normalLines); ok { + t.Error("should not detect a fallback banner in normal output") + } + + if _, _, ok := paneShowsCopilotModelFallback(nil); ok { + t.Error("nil pane should not show a fallback banner") + } +} + +func TestSeedRestartCountUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + m.SeedRestartCount("scanner", 42) + m.mu.RLock() + count := m.agents["scanner"].RestartCount + m.mu.RUnlock() + if count != 42 { + t.Errorf("restart count = %d, want 42", count) + } +} + +func TestSeedLastKick(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + now := time.Now() + m.SeedLastKick("scanner", now) + m.mu.RLock() + kick := m.agents["scanner"].LastKick + m.mu.RUnlock() + if kick == nil || !kick.Equal(now) { + t.Error("last kick not seeded correctly") + } +} + +func TestSeedPauseStateUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + now := time.Now() + m.SeedPauseState("scanner", now, "test-trigger", "test-reason", "test-user") + m.mu.RLock() + agent := m.agents["scanner"] + m.mu.RUnlock() + if agent.PausedTrigger != "test-trigger" { + t.Errorf("trigger = %q", agent.PausedTrigger) + } + if agent.PausedReason != "test-reason" { + t.Errorf("reason = %q", agent.PausedReason) + } +} + +func TestPinCLIAndUnpinUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + m.PinCLI("scanner", "copilot") + m.mu.RLock() + pinned := m.agents["scanner"].PinnedCLI + m.mu.RUnlock() + if pinned != "copilot" { + t.Errorf("pinned CLI = %q", pinned) + } + + m.UnpinCLI("scanner") + m.mu.RLock() + pinned = m.agents["scanner"].PinnedCLI + m.mu.RUnlock() + if pinned != "" { + t.Error("should be unpinned") + } +} + +func TestPinModelAndUnpinUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + m.PinModel("scanner", "opus") + m.mu.RLock() + pinned := m.agents["scanner"].PinnedModel + m.mu.RUnlock() + if pinned != "opus" { + t.Errorf("pinned model = %q", pinned) + } + + m.UnpinModel("scanner") + m.mu.RLock() + pinned = m.agents["scanner"].PinnedModel + m.mu.RUnlock() + if pinned != "" { + t.Error("should be unpinned") + } +} + +func TestSetModelOverrideUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + m.SetModelOverride("scanner", "opus") + m.mu.RLock() + override := m.agents["scanner"].ModelOverride + m.mu.RUnlock() + if override != "opus" { + t.Errorf("model override = %q", override) + } +} + +func TestSetBackendOverrideUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + m.SetBackendOverride("scanner", "copilot") + m.mu.RLock() + override := m.agents["scanner"].BackendOverride + m.mu.RUnlock() + if override != "copilot" { + t.Errorf("backend override = %q", override) + } +} + +func TestClearModeOverridesUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude", Mode: "ISSUES_AND_PRS"}, + "quality": {Backend: "claude", Mode: "ADVISORY"}, + "reviewer": {Backend: "claude", Mode: "ADVISORY"}, + }, slog.Default(), ProjectContext{}) + + // Names outside the process table are ignored, not an error. + m.ClearModeOverrides([]string{"scanner", "quality", "not-an-agent"}) + + m.mu.RLock() + scanMode := m.agents["scanner"].Config.Mode + qualMode := m.agents["quality"].Config.Mode + revMode := m.agents["reviewer"].Config.Mode + m.mu.RUnlock() + + if scanMode != "" || qualMode != "" { + t.Error("named mode overrides should be cleared") + } + // The agent NOT named keeps its operator-configured mode (#7503). + if revMode != "ADVISORY" { + t.Errorf("unnamed agent's mode = %q, want ADVISORY untouched", revMode) + } +} + +func TestIsPausedUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + if m.IsPaused("scanner") { + t.Error("should not be paused initially") + } + + m.Pause("scanner", "test", "testing") + if !m.IsPaused("scanner") { + t.Error("should be paused after Pause()") + } +} + +func TestGetStatusSnapshot(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {ID: "scan-001", Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + proc, err := m.GetStatus("scanner") + if err != nil { + t.Fatal(err) + } + if proc.Name != "scanner" { + t.Errorf("name = %q", proc.Name) + } + if proc.ID != "scan-001" { + t.Errorf("id = %q", proc.ID) + } +} + +func TestGetStatusNotFound(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{}, slog.Default(), ProjectContext{}) + _, err := m.GetStatus("nonexistent") + if err == nil { + t.Error("should error for nonexistent agent") + } +} + +func TestAllStatuses(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + "quality": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + statuses := m.AllStatuses() + if len(statuses) != 2 { + t.Errorf("expected 2 statuses, got %d", len(statuses)) + } +} + +func TestSetBootstrapOverrideUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + m.SetBootstrapOverride("scanner", "custom prompt") + m.mu.RLock() + override := m.agents["scanner"].BootstrapOverride + m.mu.RUnlock() + if override != "custom prompt" { + t.Errorf("bootstrap override = %q", override) + } +} + +func TestGetBufferOutput(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + output, err := m.GetBufferOutput("scanner", 10) + if err != nil { + t.Fatal(err) + } + _ = output +} + +func TestGetOutputNotFound(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{}, slog.Default(), ProjectContext{}) + _, err := m.GetOutput("nonexistent", 10) + if err == nil { + t.Error("should error for nonexistent agent") + } +} + +func TestTmuxSessionUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + session := m.TmuxSession("scanner") + if session != "hive-scanner" { + t.Errorf("session = %q, want 'hive-scanner'", session) + } +} + +func TestTmuxSessionNotFoundUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{}, slog.Default(), ProjectContext{}) + session := m.TmuxSession("nonexistent") + if session != "" { + t.Error("should return empty for nonexistent") + } +} + +func TestAddAgentUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{}, slog.Default(), ProjectContext{}) + + m.AddAgent("scanner", config.AgentConfig{ID: "scan-001", Backend: "claude"}) + m.mu.RLock() + _, ok := m.agents["scanner"] + m.mu.RUnlock() + if !ok { + t.Error("agent should be added") + } +} + +func TestRemoveAgentUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + m.RemoveAgent("scanner") + m.mu.RLock() + _, ok := m.agents["scanner"] + m.mu.RUnlock() + if ok { + t.Error("agent should be removed") + } +} + +func TestUpdateConfig(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude", Model: "sonnet"}, + }, slog.Default(), ProjectContext{}) + + newCfg := config.AgentConfig{Backend: "copilot", Model: "gpt-4o"} + m.UpdateConfig("scanner", newCfg) + m.mu.RLock() + cfg := m.agents["scanner"].Config + m.mu.RUnlock() + if cfg.Backend != "copilot" { + t.Errorf("backend = %q", cfg.Backend) + } +} + +func TestResetRestartCountUnit(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + m.SeedRestartCount("scanner", 10) + m.ResetRestartCount("scanner") + m.mu.RLock() + count := m.agents["scanner"].RestartCount + m.mu.RUnlock() + if count != 0 { + t.Errorf("restart count = %d, want 0", count) + } +} + +func TestSyncModeFilesNoError(t *testing.T) { + dir := t.TempDir() + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude", Role: "scanner"}, + }, slog.Default(), ProjectContext{ACMMLevel: 2}) + m.workDir = dir + + os.MkdirAll(filepath.Join(dir, "scanner"), 0755) + // Should not panic + m.SyncModeFiles(2) +} + +func TestSeedKickHistory(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.Default(), ProjectContext{}) + + records := []KickRecord{ + {Timestamp: time.Now(), Agent: "scanner", Snippet: "test kick 1"}, + {Timestamp: time.Now(), Agent: "scanner", Snippet: "test kick 2"}, + } + m.SeedKickHistory("scanner", records) + + m.mu.RLock() + history := m.agents["scanner"].KickHistory + m.mu.RUnlock() + if len(history) != 2 { + t.Errorf("expected 2 kick records, got %d", len(history)) + } +} + +func TestStopNotFound(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{}, slog.Default(), ProjectContext{}) + err := m.Stop("nonexistent") + if err == nil { + t.Error("should error for nonexistent agent") + } +} + +func TestRestartNotFound(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{}, slog.Default(), ProjectContext{}) + err := m.Restart(context.TODO(), "nonexistent") + if err == nil { + t.Error("should error for nonexistent agent") + } +} + +func TestPauseNotFound(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{}, slog.Default(), ProjectContext{}) + err := m.Pause("nonexistent", "test", "test") + if err == nil { + t.Error("should error for nonexistent agent") + } +} + +func TestDefaultAgentModeByRole(t *testing.T) { + tests := []struct { + role string + level int + want AgentMode + }{ + {"scanner", 2, ModeAdvisory}, + {"scanner", 4, ModeIssuesOnly}, + {"supervisor", 4, ModeAdvisory}, + {"quality", 3, ModeIssuesAndPRs}, + } + for _, tt := range tests { + got := DefaultAgentMode(tt.role, tt.level) + if got != tt.want { + t.Errorf("DefaultAgentMode(%q, %d) = %s, want %s", tt.role, tt.level, got, tt.want) + } + } +} diff --git a/agent/agent_working_test.go b/agent/agent_working_test.go new file mode 100644 index 0000000..b1c5b9d --- /dev/null +++ b/agent/agent_working_test.go @@ -0,0 +1,196 @@ +package agent + +import ( + "testing" + "time" + + "github.com/hivecommons/crux/config" +) + +// TestPaneShowsAgentWorking is the regression guard for #7085: modern TUI +// backends (Claude Code / Claude Fable) keep the "❯" input box rendered while +// the agent is actively working, so paneShowsInputPrompt's "❯" check passes on +// a busy agent and a kick would Ctrl+C + /clear its in-flight work. The gate is +// safety-critical in BOTH directions — too loose keeps destroying live work, +// too tight wedges a genuinely idle agent forever — so the idle and +// scrollback-only cases below are hard regression guards, not nice-to-haves. +func TestPaneShowsAgentWorking(t *testing.T) { + // The exact mid-task capture quoted in #7085: "❯" and the working + // indicators ("◉ Working", "esc interrupt") are present at the same time. + busyClaudeFable := ` ● Read shell output Waiting up to 420 seconds for command output 2m 35s + /data/agents/scanner [⎇ deps-lucide*+%] Session: 430 AIC used +──────────────────────────────────────────────────────────────────────────── +❯ +──────────────────────────────────────────────────────────────────────────── + ◉ Working · 42.6 KiB esc interrupt Claude Fable 5` + + // A genuine idle prompt: the "❯" input box is drawn but there is no + // working indicator anywhere in the visible pane. Must read as ready. + idlePrompt := `──────────────────────────────────────────────────────────────────────────── +❯ +──────────────────────────────────────────────────────────────────────────── + /data/agents/scanner [⎇ deps-lucide*+%] Session: 430 AIC used + Claude Fable 5` + + // A COMPLETED task followed by a fresh idle prompt. The working marker + // ("◉ Working ... esc interrupt") is from the finished turn and would only + // appear here if a caller passed scrollback instead of the visible pane. + // The visible region is idle and must read as ready — otherwise the agent + // is never kicked again and the hive stalls silently. + idleVisibleWorkingInScrollback := `──────────────────────────────────────────────────────────────────────────── +❯ +──────────────────────────────────────────────────────────────────────────── + done · 42.6 KiB Claude Fable 5` + + escInterruptOnly := `Processing your request… + ◉ Working · 12.1 KiB esc interrupt Claude Fable 5` + + spinnerOnly := ` ◉ Working · 3.0 KiB Claude Fable 5 +❯` + + waitingForBackgroundAgents := `❯ +──────── + ● Waiting for background agents · 500 B esc stop agents` + + legacyEscToInterrupt := `❯ +──────── + ◉ Working · 29.1 KiB esc to interrupt` + + ompRunningCancel := `⏺ Running… (esc to cancel) +❯` + + // goose and codex render no working indicator at all; their idle panes + // must never be misclassified as working. + gooseIdle := "goose is ready\n> " + codexIdle := "› \nPress enter to continue" + + cases := []struct { + name string + pane string + want bool + }{ + {"empty pane is not working", "", false}, + {"busy claude fable capture (#7085) is working", busyClaudeFable, true}, + {"genuine idle prompt is not working", idlePrompt, false}, + {"visible idle prompt, no working marker", idleVisibleWorkingInScrollback, false}, + {"esc interrupt footer is working", escInterruptOnly, true}, + {"working spinner is working", spinnerOnly, true}, + {"waiting for background agents footer is working", waitingForBackgroundAgents, true}, + {"legacy esc to interrupt footer is working", legacyEscToInterrupt, true}, + {"omp esc to cancel footer is working", ompRunningCancel, true}, + {"goose idle is not working", gooseIdle, false}, + {"codex idle is not working", codexIdle, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := paneShowsAgentWorking(tc.pane); got != tc.want { + t.Errorf("paneShowsAgentWorking(%s) = %v, want %v", tc.name, got, tc.want) + } + }) + } +} + +// TestPaneShowsAgentWorking_ReadinessContract ties the working check back to the +// readiness decision the way waitForInputPromptForAgent uses it: a pane is +// READY only when paneShowsInputPrompt sees a prompt AND the visible pane is +// not actively working. The scrollback-only case is the critical one — the +// readiness path feeds the VISIBLE pane to paneShowsAgentWorking, so a lingering +// marker in history must not block a genuinely idle agent. +func TestPaneShowsAgentWorking_ReadinessContract(t *testing.T) { + // ready(visible) mirrors the gate in waitForInputPromptForAgent: the + // visible pane decides "working", the (full) capture decides "has prompt". + ready := func(visible, fullCapture string) bool { + if paneShowsAgentWorking(visible) { + return false + } + return paneShowsInputPrompt(fullCapture) + } + + busy := ` ◉ Working · 42.6 KiB esc interrupt Claude Fable 5 +❯` + if ready(busy, busy) { + t.Errorf("busy pane classified READY; must be not ready (#7085)") + } + + idle := `──────────────────────────────────────────────────────────────────────────── +❯ +──────────────────────────────────────────────────────────────────────────── + idle Claude Fable 5` + if !ready(idle, idle) { + t.Errorf("idle pane classified NOT ready; must be ready (no regression)") + } + + // The working marker is only in scrollback; the VISIBLE pane is idle. + visibleIdle := "────────────\n❯\n────────────\n idle Claude Fable 5" + fullWithScrollback := " ◉ Working · 9 KiB esc interrupt\n" + visibleIdle + if !ready(visibleIdle, fullWithScrollback) { + t.Errorf("idle visible pane with working marker in scrollback classified " + + "NOT ready; must be ready or the agent is never kicked again") + } + + fullWithWaitingFooterScrollback := " ● Waiting for background agents · 500 B esc stop agents\n" + visibleIdle + if !ready(visibleIdle, fullWithWaitingFooterScrollback) { + t.Errorf("idle visible pane with background-agent footer in scrollback classified " + + "NOT ready; must be ready because only the visible pane decides busy") + } +} + +// TestWaitForInputPrompt_WorkingGate exercises the real readiness path +// (waitForInputPromptForAgent) through the terminal seam, proving the working +// check is wired to the VISIBLE pane. This is the call-site regression guard +// for #7085 and the surface the scrollback mutation must break. +func TestWaitForInputPrompt_WorkingGate(t *testing.T) { + origTimeout, origPoll := inputPromptTimeout, inputPromptPollInterval + inputPromptTimeout = 400 * time.Millisecond + inputPromptPollInterval = 20 * time.Millisecond + defer func() { + inputPromptTimeout, inputPromptPollInterval = origTimeout, origPoll + }() + + newAgent := func() (*Manager, *AgentProcess) { + m := NewManager( + map[string]config.AgentConfig{"scanner": {Backend: "claude"}}, + discardLogger(), ProjectContext{}) + m.mu.Lock() + agent := m.agents["scanner"] + m.mu.Unlock() + return m, agent + } + + busyVisible := " ◉ Working · 42.6 KiB esc interrupt Claude Fable 5\n❯" + idleVisible := "────────\n❯\n────────\n idle Claude Fable 5" + + // Busy: the VISIBLE pane shows a working indicator, so the gate must + // never report ready — it must ride inputPromptTimeout out to false. + m, agent := newAgent() + ft := termSeams(m) + ft.captureVisiblePane = func(*AgentProcess) string { return busyVisible } + ft.capturePane = func(*AgentProcess) string { return busyVisible } + if m.waitForInputPromptForAgent(agent) { + t.Error("busy pane: waitForInputPromptForAgent returned ready; " + + "want not ready (#7085)") + } + + // Idle: a genuine idle prompt must still read as ready (no regression). + m, agent = newAgent() + ft = termSeams(m) + ft.captureVisiblePane = func(*AgentProcess) string { return idleVisible } + ft.capturePane = func(*AgentProcess) string { return idleVisible } + if !m.waitForInputPromptForAgent(agent) { + t.Error("idle pane: waitForInputPromptForAgent returned not ready; " + + "want ready") + } + + // Working marker ONLY in scrollback: the visible pane is idle, but the + // full capture (with scrollback) still carries a finished task's marker. + // The gate consults the VISIBLE pane for working, so it must read READY. + // If the check ever searches scrollback, this agent is never kicked again. + m, agent = newAgent() + ft = termSeams(m) + ft.captureVisiblePane = func(*AgentProcess) string { return idleVisible } + ft.capturePane = func(*AgentProcess) string { return busyVisible + "\n" + idleVisible } + if !m.waitForInputPromptForAgent(agent) { + t.Error("working marker in scrollback only: returned not ready; " + + "want ready (gate must use the visible pane, not scrollback)") + } +} diff --git a/agent/agentspecwire.go b/agent/agentspecwire.go new file mode 100644 index 0000000..d145be3 --- /dev/null +++ b/agent/agentspecwire.go @@ -0,0 +1,97 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/hivecommons/crux/config" + "github.com/hivecommons/crux/skillreg" +) + +func (m *Manager) applyAgentSpec(agent *AgentProcess) error { + cfg := agent.Config + if strings.TrimSpace(agent.baseConfig.AgentSpec) != "" { + cfg = agent.baseConfig + } + cfg, prompt, applied, err := applyAgentSpecConfig(cfg) + if err != nil { + return err + } + if !applied { + agent.clearAgentSpecPromptIfSpecRemoved(cfg) + return nil + } + agent.Config = cfg + prompt = strings.TrimSpace(prompt) + if strings.TrimSpace(agent.BootstrapOverride) == "" || strings.TrimSpace(agent.BootstrapOverride) == agent.agentSpecPrompt { + agent.BootstrapOverride = prompt + } + agent.agentSpecPrompt = prompt + if m != nil && m.logger != nil { + m.logger.Info("loaded agent spec", "agent", agent.Name, "spec", cfg.AgentSpec) + } + return nil +} + +func (agent *AgentProcess) clearAgentSpecPromptIfSpecRemoved(cfg config.AgentConfig) { + if strings.TrimSpace(cfg.AgentSpec) != "" { + return + } + if agent.agentSpecPrompt != "" && strings.TrimSpace(agent.BootstrapOverride) == agent.agentSpecPrompt { + agent.BootstrapOverride = "" + } + agent.agentSpecPrompt = "" +} + +func applyAgentSpecConfig(cfg config.AgentConfig) (config.AgentConfig, string, bool, error) { + if strings.TrimSpace(cfg.AgentSpec) == "" { + return cfg, "", false, nil + } + spec, err := skillreg.LoadAgentSpec(cfg.AgentSpec) + if err != nil { + return cfg, "", false, err + } + cfg.Backend = spec.Backend() + cfg.Model = spec.Model() + cfg.Mode = agentSpecMode(spec.Mode()) + cfg.LaunchCmd = "" + cfg.Tools = nil + cfg.Skills = nil + if spec.LaunchCommand != "" { + cfg.LaunchCmd = spec.LaunchCommand + } + if len(spec.Skills) > 0 { + cfg.Skills = append([]string(nil), spec.Skills...) + } + if cfg.ReposOwner != config.FieldOwnerOperator { + cfg.Repos = append([]string(nil), skillreg.SpecRepos(spec)...) + cfg.ReposOwner = config.FieldOwnerSpec + } + if spec.Tools != nil { + cfg.Tools = &config.ToolsConfig{ + Preset: spec.Tools.Preset, + Rules: make([]config.ToolRule, 0, len(spec.Tools.Rules)), + } + for _, rule := range spec.Tools.Rules { + cfg.Tools.Rules = append(cfg.Tools.Rules, config.ToolRule{ + Pattern: rule.Pattern, + Action: rule.Action, + Reason: rule.Reason, + }) + } + } + return cfg, spec.Prompt, true, nil +} + +func agentSpecMode(mode skillreg.AgentMode) string { + switch mode { + case skillreg.ModeObserve: + return "ADVISORY" + case skillreg.ModeAutonomous: + return "ISSUES_PRS_MERGE" + case skillreg.ModeSuggest: + return "ISSUES_AND_PRS" + default: + return fmt.Sprint(mode) + } +} diff --git a/agent/agentspecwire_test.go b/agent/agentspecwire_test.go new file mode 100644 index 0000000..63a9836 --- /dev/null +++ b/agent/agentspecwire_test.go @@ -0,0 +1,304 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/hivecommons/crux/config" +) + +func TestApplyAgentSpecConfigChangesLaunchCommand(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + spec := `name: custom-reviewer +backend: claude +model: claude-opus-4-6 +mode: autonomous +launch_cmd: /opt/agents/reviewer --stdio --model claude-opus-4-6 +prompt: "Use the external review checklist." +tools: + preset: full +skills: + - review-checklist +` + if err := os.WriteFile(path, []byte(spec), 0o644); err != nil { + t.Fatal(err) + } + + cfg, prompt, applied, err := applyAgentSpecConfig(config.AgentConfig{ + Backend: "copilot", + Model: "auto", + Mode: "ADVISORY", + LaunchCmd: "copilot --model auto", + AgentSpec: path, + }) + if err != nil { + t.Fatalf("applyAgentSpecConfig: %v", err) + } + if !applied { + t.Fatal("agent spec was not applied") + } + if cfg.Backend != "claude" { + t.Errorf("backend = %q, want claude", cfg.Backend) + } + if cfg.Model != "claude-opus-4-6" { + t.Errorf("model = %q", cfg.Model) + } + if cfg.Mode != "ISSUES_PRS_MERGE" { + t.Errorf("mode = %q, want ISSUES_PRS_MERGE", cfg.Mode) + } + if cfg.LaunchCmd != "/opt/agents/reviewer --stdio --model claude-opus-4-6" { + t.Errorf("launch_cmd = %q", cfg.LaunchCmd) + } + if prompt != "Use the external review checklist." { + t.Errorf("prompt = %q", prompt) + } + if cfg.Tools == nil || cfg.Tools.Preset != "full" { + t.Fatalf("tools = %+v, want full preset", cfg.Tools) + } + if len(cfg.Skills) != 1 || cfg.Skills[0] != "review-checklist" { + t.Errorf("skills = %v", cfg.Skills) + } +} + +func TestApplyAgentSpecConfigDirectoryRef(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "spec.yaml"), []byte("name: a\nbackend: copilot\nmodel: auto\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, _, applied, err := applyAgentSpecConfig(config.AgentConfig{AgentSpec: dir, LaunchCmd: "stale custom launcher"}) + if err != nil { + t.Fatalf("applyAgentSpecConfig(dir): %v", err) + } + if !applied || cfg.Backend != "copilot" || cfg.Model != "auto" { + t.Fatalf("cfg = %+v applied=%v", cfg, applied) + } + if cfg.LaunchCmd != "" { + t.Fatalf("launch_cmd = %q, want cleared when spec omits launch_cmd", cfg.LaunchCmd) + } +} + +func TestApplyAgentSpecNoRefAndErrors(t *testing.T) { + cfg, prompt, applied, err := applyAgentSpecConfig(config.AgentConfig{Backend: "copilot"}) + if err != nil { + t.Fatalf("no-ref error = %v", err) + } + if applied || prompt != "" || cfg.Backend != "copilot" { + t.Fatalf("cfg=%+v prompt=%q applied=%v", cfg, prompt, applied) + } + if _, _, _, err := applyAgentSpecConfig(config.AgentConfig{AgentSpec: filepath.Join(t.TempDir(), "missing.yaml")}); err == nil { + t.Fatal("missing agent_spec should error") + } +} + +func TestApplyAgentSpecSetsBootstrapPromptUnlessOverridden(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + if err := os.WriteFile(path, []byte("name: a\nbackend: copilot\nmodel: auto\nmode: observe\nprompt: spec prompt\n"), 0o644); err != nil { + t.Fatal(err) + } + m := &Manager{} + agent := &AgentProcess{Name: "a", Config: config.AgentConfig{AgentSpec: path}} + if err := m.applyAgentSpec(agent); err != nil { + t.Fatalf("applyAgentSpec: %v", err) + } + if agent.BootstrapOverride != "spec prompt" { + t.Fatalf("BootstrapOverride = %q", agent.BootstrapOverride) + } + if agent.Config.Mode != "ADVISORY" { + t.Fatalf("mode = %q", agent.Config.Mode) + } + + agent = &AgentProcess{Name: "a", BootstrapOverride: "operator prompt", Config: config.AgentConfig{AgentSpec: path}} + if err := m.applyAgentSpec(agent); err != nil { + t.Fatalf("applyAgentSpec with override: %v", err) + } + if agent.BootstrapOverride != "operator prompt" { + t.Fatalf("BootstrapOverride = %q, want operator prompt", agent.BootstrapOverride) + } +} + +func TestApplyAgentSpecUpdatesAndClearsSpecOwnedPrompt(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + if err := os.WriteFile(path, []byte("name: a\nbackend: copilot\nmodel: auto\nmode: observe\nprompt: first prompt\n"), 0o644); err != nil { + t.Fatal(err) + } + agent := &AgentProcess{Name: "a", Config: config.AgentConfig{AgentSpec: path}} + m := &Manager{} + if err := m.applyAgentSpec(agent); err != nil { + t.Fatalf("applyAgentSpec first: %v", err) + } + if agent.BootstrapOverride != "first prompt" { + t.Fatalf("BootstrapOverride = %q", agent.BootstrapOverride) + } + + if err := os.WriteFile(path, []byte("name: a\nbackend: copilot\nmodel: auto\nmode: observe\nprompt: updated prompt\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := m.applyAgentSpec(agent); err != nil { + t.Fatalf("applyAgentSpec update: %v", err) + } + if agent.BootstrapOverride != "updated prompt" { + t.Fatalf("BootstrapOverride after update = %q", agent.BootstrapOverride) + } + + if err := os.WriteFile(path, []byte("name: a\nbackend: copilot\nmodel: auto\nmode: observe\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := m.applyAgentSpec(agent); err != nil { + t.Fatalf("applyAgentSpec removal: %v", err) + } + if agent.BootstrapOverride != "" { + t.Fatalf("BootstrapOverride after prompt removal = %q", agent.BootstrapOverride) + } +} + +func TestApplyAgentSpecDoesNotReplaceOperatorPromptOnSpecUpdate(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + if err := os.WriteFile(path, []byte("name: a\nbackend: copilot\nmodel: auto\nmode: observe\nprompt: spec prompt\n"), 0o644); err != nil { + t.Fatal(err) + } + agent := &AgentProcess{Name: "a", Config: config.AgentConfig{AgentSpec: path}} + m := &Manager{} + if err := m.applyAgentSpec(agent); err != nil { + t.Fatalf("applyAgentSpec first: %v", err) + } + agent.BootstrapOverride = "operator prompt" + if err := os.WriteFile(path, []byte("name: a\nbackend: copilot\nmodel: auto\nmode: observe\nprompt: changed spec prompt\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := m.applyAgentSpec(agent); err != nil { + t.Fatalf("applyAgentSpec update: %v", err) + } + if agent.BootstrapOverride != "operator prompt" { + t.Fatalf("BootstrapOverride = %q, want operator prompt", agent.BootstrapOverride) + } +} + +func TestUpdateConfigRemovingAgentSpecClearsSpecOwnedPrompt(t *testing.T) { + agent := &AgentProcess{ + Name: "a", + Config: config.AgentConfig{AgentSpec: "agent.yaml"}, + baseConfig: config.AgentConfig{AgentSpec: "agent.yaml"}, + BootstrapOverride: "spec prompt", + agentSpecPrompt: "spec prompt", + } + m := &Manager{agents: map[string]*AgentProcess{"a": agent}} + + if err := m.UpdateConfig("a", config.AgentConfig{Backend: "copilot"}); err != nil { + t.Fatalf("UpdateConfig: %v", err) + } + if agent.BootstrapOverride != "" || agent.agentSpecPrompt != "" { + t.Fatalf("spec prompt survived removal: bootstrap=%q tracked=%q", agent.BootstrapOverride, agent.agentSpecPrompt) + } +} + +func TestUpdateConfigRemovingAgentSpecPreservesOperatorPrompt(t *testing.T) { + agent := &AgentProcess{ + Name: "a", + Config: config.AgentConfig{AgentSpec: "agent.yaml"}, + baseConfig: config.AgentConfig{AgentSpec: "agent.yaml"}, + BootstrapOverride: "operator prompt", + agentSpecPrompt: "spec prompt", + } + m := &Manager{agents: map[string]*AgentProcess{"a": agent}} + + if err := m.UpdateConfig("a", config.AgentConfig{Backend: "copilot"}); err != nil { + t.Fatalf("UpdateConfig: %v", err) + } + if agent.BootstrapOverride != "operator prompt" || agent.agentSpecPrompt != "" { + t.Fatalf("operator prompt not preserved: bootstrap=%q tracked=%q", agent.BootstrapOverride, agent.agentSpecPrompt) + } +} + +func TestApplyAgentSpecUsesBaseConfigAndRevokesRemovedSpecFields(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + if err := os.WriteFile(path, []byte("name: a\nbackend: claude\nmodel: sonnet\nmode: suggest\nlaunch_cmd: first\ntools:\n preset: full\nskills:\n - broad\n"), 0o644); err != nil { + t.Fatal(err) + } + base := config.AgentConfig{ + AgentSpec: path, + Backend: "copilot", + Model: "auto", + Tools: &config.ToolsConfig{Preset: "advisory"}, + Skills: []string{"base-skill"}, + } + agent := &AgentProcess{Name: "a", Config: base, baseConfig: base} + m := &Manager{} + if err := m.applyAgentSpec(agent); err != nil { + t.Fatalf("applyAgentSpec first: %v", err) + } + if agent.Config.LaunchCmd != "first" || agent.Config.Tools == nil || agent.Config.Tools.Preset != "full" || len(agent.Config.Skills) != 1 { + t.Fatalf("first apply cfg = %+v", agent.Config) + } + + if err := os.WriteFile(path, []byte("name: a\nbackend: claude\nmodel: sonnet\nmode: suggest\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := m.applyAgentSpec(agent); err != nil { + t.Fatalf("applyAgentSpec second: %v", err) + } + if agent.Config.LaunchCmd != "" || agent.Config.Tools != nil || agent.Config.Skills != nil { + t.Fatalf("removed spec fields persisted: launch=%q tools=%+v skills=%v", agent.Config.LaunchCmd, agent.Config.Tools, agent.Config.Skills) + } +} + +func TestRestartValidatesAgentSpecBeforeTeardown(t *testing.T) { + cancelled := false + agent := &AgentProcess{ + Name: "a", + State: StateRunning, + Paused: true, + RestartCount: 7, + Config: config.AgentConfig{ + AgentSpec: filepath.Join(t.TempDir(), "missing.yaml"), + }, + cancel: func() { cancelled = true }, + } + m := &Manager{agents: map[string]*AgentProcess{"a": agent}} + + if err := m.Restart(context.Background(), "a"); err == nil { + t.Fatal("Restart with missing agent_spec succeeded") + } + if cancelled { + t.Fatal("Restart cancelled running agent before validating agent_spec") + } + if agent.RestartCount != 7 || agent.forceRelaunch { + t.Fatalf("agent teardown fields changed: restart_count=%d force=%v", agent.RestartCount, agent.forceRelaunch) + } +} + +func TestRestartWithBootstrapValidatesAgentSpecBeforeMutating(t *testing.T) { + agent := &AgentProcess{ + Name: "a", + State: StateRunning, + Paused: true, + BootstrapOverride: "existing", + Config: config.AgentConfig{ + AgentSpec: filepath.Join(t.TempDir(), "missing.yaml"), + }, + cancel: func() { t.Fatal("cancel called before agent_spec validation") }, + } + m := &Manager{agents: map[string]*AgentProcess{"a": agent}} + + if err := m.RestartWithBootstrap(context.Background(), "a", "replacement"); err == nil { + t.Fatal("RestartWithBootstrap with missing agent_spec succeeded") + } + if agent.BootstrapOverride != "existing" || !agent.Paused { + t.Fatalf("agent mutated before validation: bootstrap=%q paused=%v", agent.BootstrapOverride, agent.Paused) + } +} + +func TestAgentSpecModeMappings(t *testing.T) { + if got := agentSpecMode("unexpected"); got != "unexpected" { + t.Fatalf("unexpected mode = %q", got) + } + if got := agentSpecMode("suggest"); got != "ISSUES_AND_PRS" { + t.Fatalf("suggest = %q", got) + } +} diff --git a/agent/agy_launch_test.go b/agent/agy_launch_test.go new file mode 100644 index 0000000..4c87c87 --- /dev/null +++ b/agent/agy_launch_test.go @@ -0,0 +1,172 @@ +package agent + +// Launch-command test for the agy (Antigravity CLI) backend added in #3910. +// The contract under pin is the flag set the commit's analysis established: +// +// - --dangerously-skip-permissions is ALWAYS passed — without it agy blocks +// on a per-tool approval prompt no one is attached to answer; +// - a configured model is passed as --model --effort , deriving +// the effort from agy's -low/-medium/-high model suffix when the agent has +// no explicit reasoning_effort, because agy silently IGNORES --model +// without --effort — dropping or mismatching the effort flag would make the +// configured model a no-op while looking fine. +// +// This test used to launch a real tmux pane with an agy stub and poll +// CaptureFullLog for ~35s waiting for the typed command to echo back. That is +// environment- and timing-dependent: in CI the pane frequently never +// materialized, the capture came back EMPTY, and the whole package failed — +// which zeroed pkg/agent's coverage score and re-fired the coverage floor +// issue even though no package had actually regressed (#5299). +// +// The flag contract lives in backendLaunchCmd, a pure function, so it is +// asserted directly here. The assertions themselves are unchanged: same flags, +// same model, same reason strings. + +import ( + "strings" + "testing" +) + +// agyLaunchCmd builds the agy launch command the way Start does for an agent +// with no ToolsConfig: normalize the configured model for the backend, then +// hand it to backendLaunchCmd. Keeping the normalization step means the test +// still covers the model plumbing, not just the fmt.Sprintf. +func agyInteractiveLaunchCmdForTest(t *testing.T, model, effort string) string { + t.Helper() + t.Setenv(agyLaunchModeEnv, agyLaunchModeInteractive) + const backend = "agy" + isInference := IsInferenceBackend(backend) + return backendLaunchCmd("agy", normalizeModelNameForBackend(model, backend, isInference), backend, isInference, effort) +} + +func TestAgyLaunchCommandLine_DefaultsToHeadlessShim(t *testing.T) { + t.Setenv(agyLaunchModeEnv, "") + cmd := agyLaunchCmdForTest(t, "gemini-pro", "") + + if strings.Contains(cmd, "agy --dangerously-skip-permissions") { + t.Fatalf("default agy launch must not start the CPU-hungry interactive TUI; cmd: %q", cmd) + } + if !strings.Contains(cmd, agyHeadlessReadyMarker) { + t.Fatalf("default agy launch must install the headless ready marker; cmd: %q", cmd) + } +} + +func agyLaunchCmdForTest(t *testing.T, model, effort string) string { + t.Helper() + const backend = "agy" + isInference := IsInferenceBackend(backend) + return backendLaunchCmd("agy", normalizeModelNameForBackend(model, backend, isInference), backend, isInference, effort) +} + +// TestStart_AgyLaunchCommandLine asserts the command line agy is launched with +// carries the #3910 flag contract. +func TestStart_AgyLaunchCommandLine(t *testing.T) { + // "gemini-pro" survives normalizeModelName unchanged (no trailing digit + // segment), so the assertion below sees the configured model verbatim. + cmd := agyInteractiveLaunchCmdForTest(t, "gemini-pro", "") + + if !strings.Contains(cmd, "--dangerously-skip-permissions") { + t.Errorf("agy launched without --dangerously-skip-permissions — it will block on a per-tool approval prompt no one answers; cmd: %q", cmd) + } + if !strings.Contains(cmd, "--model gemini-pro --effort "+agyDefaultEffort) { + t.Errorf("agy launched without '--model gemini-pro --effort %s' — agy silently ignores --model without --effort, so the configured model would never take effect; cmd: %q", + agyDefaultEffort, cmd) + } +} + +// TestAgyLaunchCommandLine_NoModel pins the other half of the contract: with no +// model configured, agy still gets the bypass flag, and it must NOT be given a +// bare --model/--effort pair built from an empty model. +func TestAgyLaunchCommandLine_NoModel(t *testing.T) { + cmd := agyInteractiveLaunchCmdForTest(t, "", "") + + if !strings.Contains(cmd, "--dangerously-skip-permissions") { + t.Errorf("agy must get --dangerously-skip-permissions even with no model configured; cmd: %q", cmd) + } + if strings.Contains(cmd, "--model") || strings.Contains(cmd, "--effort") { + t.Errorf("agy with no configured model must not be passed --model/--effort; cmd: %q", cmd) + } +} + +// TestAgyLaunchCommandLine_ConfiguredEffort pins the per-agent +// reasoning_effort plumbing: an effort agy accepts replaces agyDefaultEffort, +// and one agy rejects (codex's wider vocabulary) falls back to the default +// rather than making agy ignore the model outright. +func TestAgyLaunchCommandLine_ConfiguredEffort(t *testing.T) { + if cmd := agyInteractiveLaunchCmdForTest(t, "gemini-pro", "high"); !strings.Contains(cmd, "--model gemini-pro --effort high") { + t.Errorf("configured effort 'high' must reach agy's --effort; cmd: %q", cmd) + } + if cmd := agyInteractiveLaunchCmdForTest(t, "gemini-pro", "xhigh"); !strings.Contains(cmd, "--model gemini-pro --effort "+agyDefaultEffort) { + t.Errorf("effort agy rejects must fall back to --effort %s when the model has no effort suffix; cmd: %q", agyDefaultEffort, cmd) + } +} + +func TestAgyLaunchCommandLine_DerivesEffortFromModelSuffix(t *testing.T) { + for _, c := range []struct { + model string + want string + }{ + {"gemini-3.8-flash-high", "high"}, + {"gemini-3.8-flash-medium", "medium"}, + {"gemini-3.8-flash-low", "low"}, + {"gemini-pro-high", "high"}, + } { + cmd := agyInteractiveLaunchCmdForTest(t, c.model, "") + want := "--model " + c.model + " --effort " + c.want + if !strings.Contains(cmd, want) { + t.Errorf("agy model suffix must set effort; missing %q in cmd: %q", want, cmd) + } + } + + cmd := agyInteractiveLaunchCmdForTest(t, "gemini-3.8-flash-high", "medium") + if !strings.Contains(cmd, "--model gemini-3.8-flash-high --effort medium") { + t.Errorf("explicit effort must override model suffix; cmd: %q", cmd) + } + + cmd = agyInteractiveLaunchCmdForTest(t, "gemini-3.8-flash-high", "xhigh") + if !strings.Contains(cmd, "--model gemini-3.8-flash-high --effort high") { + t.Errorf("invalid effort must fall back to model suffix rather than downgrading; cmd: %q", cmd) + } +} + +func TestAgyHeadlessTurnCommandRunsTurnRunner(t *testing.T) { + cmd := agyHeadlessTurnShellCommand("/usr/local/bin/hive", "agy", "gemini-pro", "high", "/data/agents/a/.hive-agy-prompt-a.txt", false) + + if strings.Contains(cmd, "do the task") || strings.Contains(cmd, "$(cat") { + t.Fatalf("headless turn command must not expand the prompt into argv (E2BIG past 128 KiB); cmd: %q", cmd) + } + for _, want := range []string{ + "'/usr/local/bin/hive' agy-turn --agy 'agy' --model 'gemini-pro' --effort 'high' --prompt-file '/data/agents/a/.hive-agy-prompt-a.txt'", + `--conversation-file "${` + agyConversationFileVar + `:-}"`, + agyHeadlessRunningMarker, + agyHeadlessReadyMarker, + } { + if !strings.Contains(cmd, want) { + t.Errorf("headless turn command missing %q; cmd: %q", want, cmd) + } + } + if strings.Contains(cmd, "--new-conversation") { + t.Errorf("a normal kick must continue the agent's conversation; cmd: %q", cmd) + } + if cmd := agyHeadlessTurnShellCommand("hive", "agy", "", "", "/p", true); !strings.Contains(cmd, "--new-conversation") { + t.Errorf("ClearOnKick must start a new conversation; cmd: %q", cmd) + } + if cmd := agyHeadlessTurnShellCommand("hive", "agy", "", "high", "/p", false); strings.Contains(cmd, "--model") || strings.Contains(cmd, "--effort") { + t.Errorf("no model configured must pass neither --model nor --effort; cmd: %q", cmd) + } +} + +func TestAgyHeadlessMarkersDrivePaneReadiness(t *testing.T) { + if !paneHasCLIMarker("prefix " + agyHeadlessReadyMarker + " suffix") { + t.Fatal("agy headless ready marker must count as a live backend") + } + if !paneShowsInputPrompt(agyHeadlessReadyMarker + "> ") { + t.Fatal("agy headless ready marker must count as an input prompt") + } + if !paneShowsAgentWorking(agyHeadlessRunningMarker) { + t.Fatal("agy headless running marker must keep kick delivery from interrupting an active turn") + } + if paneShowsAgentWorking(agyHeadlessRunningMarker + "\n" + agyHeadlessReadyMarker + "> ") { + t.Fatal("agy headless ready marker must clear an older running marker in the visible pane") + } +} diff --git a/agent/agy_restart_gate_test.go b/agent/agy_restart_gate_test.go new file mode 100644 index 0000000..65a271c --- /dev/null +++ b/agent/agy_restart_gate_test.go @@ -0,0 +1,49 @@ +package agent + +import ( + "testing" + "time" + + "github.com/hivecommons/crux/config" +) + +// TestTokenRestartHealIsBackendScoped pins the gate that stopped agents being +// restart-looped out of a sign-in. +// +// configHasTokens() is backend-blind: it answers true when EITHER the shared +// claude or copilot credential is usable, regardless of which backend the agent +// in front of it runs. Gating the token-restart heal on it meant an agy agent +// sitting at its Google OAuth prompt — or a Copilot agent with only an unrelated +// Claude login present — was restarted because the shared CLAUDE login was +// valid. A restart cannot mint those sessions, so it looped; for agy every +// relaunch minted a fresh PKCE challenge, invalidating the code the operator was +// mid-way through pasting. Measured live: agy 15 restarts/hour and Copilot +// hives repeatedly re-badged after an operator reset. +func TestTokenRestartHealIsBackendScoped(t *testing.T) { + // A live, usable shared claude credential — the thing that used to make the + // backend-blind gate answer true for every agent on the hive. + stageSharedClaudeCredential(t, map[string]any{ + "accessToken": "sk-ant-oat-live", + "expiresAt": time.Now().Add(6 * time.Hour).UnixMilli(), + }) + + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "agy"}, + "guide": {Backend: "copilot"}, + "quality": {Backend: "claude"}, + }, discardLogger(), ProjectContext{ACMMLevel: 5}) + + if configHasTokens() != true { + t.Fatal("precondition: the backend-blind check must be true here — otherwise this test proves nothing") + } + + if m.AgentHasValidCredential("scanner") { + t.Fatal("an agy agent must NOT read as credentialed off a claude login: restarting it cannot mint a Google session, and each relaunch rotates the PKCE challenge the operator is using") + } + if m.AgentHasValidCredential("guide") { + t.Fatal("a copilot agent must NOT read as credentialed off a claude login: restarting it cannot mint a Copilot session, so it must be left for the auth-required hold") + } + if !m.AgentHasValidCredential("quality") { + t.Fatal("a claude agent with a usable shared credential must still qualify — the heal's real use case (#4596) must keep working") + } +} diff --git a/agent/agy_turn.go b/agent/agy_turn.go new file mode 100644 index 0000000..c5ed039 --- /dev/null +++ b/agent/agy_turn.go @@ -0,0 +1,564 @@ +package agent + +// `hive agy-turn`: one headless agy turn, run inside the agent's tmux pane. +// +// The headless agy shim (#8656, #8669) replaced the resident agy TUI — which +// held 15–20% of a core per agent for the whole turn, even while idle +// (google-antigravity/antigravity-cli#945) — with one `agy` process per kick. +// Running that process directly as `agy -p "$(cat prompt)"` had three gaps +// this runner closes: +// +// - Continuity. The TUI kept one conversation across kicks; a bare `agy -p` +// starts a new one every time. `-c/--continue` resumes the most recent +// conversation, but every agent on a hive shares one HOME and therefore one +// agy conversation store, so "most recent" can be another agent's. The +// runner resumes by explicit id instead (`--conversation `), reading +// it from and recording it to a per-launch file the pane shell owns. +// - Prompt size. A prompt passed in argv fails with E2BIG past Linux's +// 128 KiB per-argument limit, and large ${PR_LIST}/${ISSUE_LIST} kicks +// reach that. `agy -p -` does not read stdin; the only stdin path agy +// 1.2.9 offers is `--input-format stream-json`, so the prompt goes there. +// - Legibility. stream-json output is NDJSON. The runner renders it as +// plain text in the pane (assistant text, one line per tool call) so the +// dashboard terminal, pluk and kick logs stay readable, and it keeps the +// running marker on the pane's last line for the kick and poll gates. + +import ( + "bufio" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// agyTurnStatusInterval is how often the runner refreshes the running status +// line while agy is silent (a long tool call). A var so tests can shorten it. +var agyTurnStatusInterval = 10 * time.Second + +// agyTurnInterruptGrace is how long agy gets to exit on its own after a +// Ctrl+C before the runner kills it. +const agyTurnInterruptGrace = 5 * time.Second + +const ( + agyTurnSubcommand = "agy-turn" + + // agyTurnToolOutputLines caps how much of each tool's output is echoed + // into the pane. The agent sees the full output; the pane only needs + // enough for an operator to follow along. + agyTurnToolOutputLines = 3 + agyTurnMaxLineRunes = 200 +) + +// agyStreamInput is the one stdin message agy's `--input-format stream-json` +// accepts for a user turn. The shape is not documented upstream; it was +// established against agy 1.2.9 from its own decode errors ("missing the +// \"event\" field", "cannot unmarshal string into ... streamInputUserMessage", +// "message has no content"). +type agyStreamInput struct { + Event string `json:"event"` + Message agyStreamInputMessage `json:"message"` +} + +type agyStreamInputMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// agyStreamEvent is the subset of agy's `--output-format stream-json` events +// the runner renders. Unknown events and fields are ignored. +type agyStreamEvent struct { + Event string `json:"event"` + ConversationID string `json:"conversation_id"` + StepUpdate *agyStepUpdate `json:"step_update"` + Result *agyTurnResult `json:"result"` + Init json.RawMessage `json:"init"` +} + +type agyStepUpdate struct { + ConversationID string `json:"conversation_id"` + StepIndex int `json:"step_index"` + State string `json:"state"` + StepType string `json:"step_type"` + TextDelta string `json:"text_delta"` + ToolName string `json:"tool_name"` + ToolInfo *agyToolInfo `json:"tool_info"` +} + +type agyToolInfo struct { + Name string `json:"name"` + Parameters map[string]any `json:"parameters"` + Output string `json:"output"` +} + +type agyTurnResult struct { + ConversationID string `json:"conversation_id"` + Status string `json:"status"` + Error string `json:"error"` + DurationSeconds float64 `json:"duration_seconds"` + NumTurns int `json:"num_turns"` + Usage struct { + TotalTokens int `json:"total_tokens"` + } `json:"usage"` +} + +// agyTurnOptions are the parsed `hive agy-turn` flags. +type agyTurnOptions struct { + binary string + model string + effort string + promptFile string + conversationFile string + newConversation bool +} + +// RunAgyTurn implements `hive agy-turn`. It returns the process exit code. +func RunAgyTurn(args []string, stdout, stderr io.Writer) int { + opts, err := parseAgyTurnArgs(args, stderr) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + fmt.Fprintf(stderr, "hive %s: %v\n", agyTurnSubcommand, err) + return 2 + } + return runAgyTurn(opts, stdout, stderr) +} + +func parseAgyTurnArgs(args []string, stderr io.Writer) (agyTurnOptions, error) { + var opts agyTurnOptions + fs := flag.NewFlagSet(agyTurnSubcommand, flag.ContinueOnError) + fs.SetOutput(stderr) + fs.StringVar(&opts.binary, "agy", "agy", "agy binary to run") + fs.StringVar(&opts.model, "model", "", "agy --model (passed with --effort)") + fs.StringVar(&opts.effort, "effort", "", "agy --effort; defaults to agy's own default when a model is set") + fs.StringVar(&opts.promptFile, "prompt-file", "", "file holding the prompt for this turn (required)") + fs.StringVar(&opts.conversationFile, "conversation-file", "", "file recording the conversation id to resume; empty disables continuity") + fs.BoolVar(&opts.newConversation, "new-conversation", false, "ignore the recorded conversation and start a new one") + if err := fs.Parse(args); err != nil { + return opts, err + } + if fs.NArg() > 0 { + return opts, fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " ")) + } + if opts.promptFile == "" { + return opts, errors.New("--prompt-file is required") + } + return opts, nil +} + +// agyTurnArgs builds agy's argv for one stream-json turn. +func agyTurnArgs(model, effort, conversationID string) []string { + args := []string{"--dangerously-skip-permissions"} + if model != "" { + // agy silently ignores --model without --effort (see agyInteractiveLaunchCmd). + args = append(args, "--model", model, "--effort", agyLaunchEffort(model, effort)) + } + if conversationID != "" { + args = append(args, "--conversation", conversationID) + } + return append(args, "--input-format", "stream-json", "--output-format", "stream-json") +} + +func runAgyTurn(opts agyTurnOptions, stdout, stderr io.Writer) int { + prompt, err := os.ReadFile(opts.promptFile) + if err != nil { + fmt.Fprintf(stderr, "hive %s: reading prompt: %v\n", agyTurnSubcommand, err) + return 1 + } + input, err := json.Marshal(agyStreamInput{ + Event: "user", + Message: agyStreamInputMessage{Role: "user", Content: string(prompt)}, + }) + if err != nil { + fmt.Fprintf(stderr, "hive %s: encoding prompt: %v\n", agyTurnSubcommand, err) + return 1 + } + input = append(input, '\n') + + resumeID := "" + if !opts.newConversation { + resumeID = readAgyConversationID(opts.conversationFile) + } + + cmd := exec.Command(opts.binary, agyTurnArgs(opts.model, opts.effort, resumeID)...) + cmd.Stdin = strings.NewReader(string(input)) + cmd.Stderr = stderr + out, err := cmd.StdoutPipe() + if err != nil { + fmt.Fprintf(stderr, "hive %s: %v\n", agyTurnSubcommand, err) + return 1 + } + + r := newAgyTurnRenderer(stdout, time.Now()) + r.status() + if err := cmd.Start(); err != nil { + r.finish() + fmt.Fprintf(stderr, "hive %s: starting %s: %v\n", agyTurnSubcommand, opts.binary, err) + return 1 + } + + // A Ctrl+C in the pane reaches agy too (same foreground process group). + // Outlive it rather than dying first, so the pane still gets the summary + // and the conversation id agy reported; kill agy only if it ignores the + // interrupt. + interrupts := make(chan os.Signal, 1) + signal.Notify(interrupts, os.Interrupt) + defer signal.Stop(interrupts) + + stop := make(chan struct{}) + var ticker sync.WaitGroup + ticker.Add(1) + go func() { + defer ticker.Done() + t := time.NewTicker(agyTurnStatusInterval) + defer t.Stop() + var killAfter <-chan time.Time + for { + select { + case <-stop: + return + case <-t.C: + r.status() + case <-interrupts: + if killAfter == nil { + killAfter = time.After(agyTurnInterruptGrace) + } + case <-killAfter: + _ = cmd.Process.Kill() + } + } + }() + + recorded := resumeID + sc := bufio.NewScanner(out) + sc.Buffer(make([]byte, 64*1024), 64*1024*1024) + for sc.Scan() { + id := r.handleLine(sc.Bytes()) + if id == "" || id == recorded { + continue + } + if resumeID != "" && recorded == resumeID { + r.printf("HIVE agy: conversation %s could not be resumed; agy started %s\n", resumeID, id) + } + // Record the id the moment agy reports it, not at the end: a turn + // interrupted by a restart or a kick's Ctrl+C must still be resumable. + if err := writeAgyConversationID(opts.conversationFile, id); err != nil { + r.printf("HIVE agy: recording conversation id: %v\n", err) + } + recorded = id + } + scanErr := sc.Err() + if scanErr != nil { + // Keep draining so agy never blocks on a full pipe. + _, _ = io.Copy(io.Discard, out) + } + waitErr := cmd.Wait() + close(stop) + ticker.Wait() + r.finish() + + if scanErr != nil { + fmt.Fprintf(stderr, "hive %s: reading agy output: %v\n", agyTurnSubcommand, scanErr) + } + code := 0 + var exitErr *exec.ExitError + switch { + case errors.As(waitErr, &exitErr): + code = exitErr.ExitCode() + if code < 0 { + code = 1 + } + case waitErr != nil: + fmt.Fprintf(stderr, "hive %s: %v\n", agyTurnSubcommand, waitErr) + code = 1 + } + if code == 0 && (r.result == nil || !strings.EqualFold(r.result.Status, "SUCCESS")) { + code = 1 + } + return code +} + +// readAgyConversationID returns the recorded conversation id, or "" when there +// is none (no file configured, first turn since launch, or unreadable). +func readAgyConversationID(path string) string { + if path == "" { + return "" + } + data, err := os.ReadFile(path) + if err != nil { + return "" + } + id := strings.TrimSpace(string(data)) + if !validAgyConversationID(id) { + return "" + } + return id +} + +// validAgyConversationID accepts the UUID-shaped ids agy reports and refuses +// anything that could be read as a flag or carry whitespace. +func validAgyConversationID(id string) bool { + if id == "" || len(id) > 128 || strings.HasPrefix(id, "-") { + return false + } + for _, c := range id { + if !(c == '-' || c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + return false + } + } + return true +} + +// writeAgyConversationID atomically replaces the conversation file. +func writeAgyConversationID(path, id string) error { + if path == "" || !validAgyConversationID(id) { + return nil + } + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*") + if err != nil { + return err + } + if _, err := tmp.WriteString(id + "\n"); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmp.Name()) + return err + } + if err := os.Rename(tmp.Name(), path); err != nil { + _ = os.Remove(tmp.Name()) + return err + } + return nil +} + +// agyTurnRenderer turns agy's stream-json output into pane text. +// +// Output is written whole lines at a time, and every write ends by redrawing +// a status line that carries agyHeadlessRunningMarker. The marker is therefore +// always on the pane's last line while the turn runs, however much output has +// scrolled the turn's header away: paneShowsAgentWorking keeps the kick path +// from typing into a live turn, and a partial assistant line can never be +// mistaken for the shell prompt. +type agyTurnRenderer struct { + mu sync.Mutex + w io.Writer + started time.Time + now func() time.Time + statusShown bool + pending map[int]string // step index → assistant text not yet ended by a newline + lastTool string + result *agyTurnResult +} + +func newAgyTurnRenderer(w io.Writer, started time.Time) *agyTurnRenderer { + return &agyTurnRenderer{w: w, started: started, now: time.Now, pending: map[int]string{}} +} + +// statusLine is the line the pane shows while the turn runs. +func (r *agyTurnRenderer) statusLine() string { + elapsed := r.now().Sub(r.started).Round(time.Second) + line := fmt.Sprintf("%s %s", agyHeadlessRunningMarker, elapsed) + if r.lastTool != "" { + line += " · " + r.lastTool + } + return line +} + +// emit writes complete lines (may be "") and then redraws the status line. +func (r *agyTurnRenderer) emit(text string) { + r.mu.Lock() + defer r.mu.Unlock() + var b strings.Builder + if r.statusShown { + b.WriteString("\r\x1b[K") + } + b.WriteString(text) + b.WriteString(r.statusLine()) + r.statusShown = true + _, _ = io.WriteString(r.w, b.String()) +} + +func (r *agyTurnRenderer) status() { r.emit("") } + +func (r *agyTurnRenderer) printf(format string, args ...any) { + r.emit(fmt.Sprintf(format, args...)) +} + +// finish flushes any unterminated assistant text, replaces the status line +// with the turn summary, and leaves the cursor at the start of a line for the +// shell's ready marker. +func (r *agyTurnRenderer) finish() { + r.mu.Lock() + defer r.mu.Unlock() + var b strings.Builder + if r.statusShown { + b.WriteString("\r\x1b[K") + r.statusShown = false + } + b.WriteString(r.flushPendingLocked()) + if res := r.result; res != nil { + fmt.Fprintf(&b, "HIVE agy turn: status=%s turns=%d duration=%.1fs tokens=%d conversation=%s\n", + res.Status, res.NumTurns, res.DurationSeconds, res.Usage.TotalTokens, res.ConversationID) + if res.Error != "" { + fmt.Fprintf(&b, "HIVE agy turn error: %s\n", res.Error) + } + } else { + b.WriteString("HIVE agy turn: agy exited without a result\n") + } + _, _ = io.WriteString(r.w, b.String()) +} + +func (r *agyTurnRenderer) flushPendingLocked() string { + if len(r.pending) == 0 { + return "" + } + steps := make([]int, 0, len(r.pending)) + for s := range r.pending { + steps = append(steps, s) + } + sort.Ints(steps) + var b strings.Builder + for _, s := range steps { + b.WriteString(r.pending[s]) + b.WriteString("\n") + } + r.pending = map[int]string{} + return b.String() +} + +// handleLine renders one line of agy output and returns the conversation id +// it carries, if any. +func (r *agyTurnRenderer) handleLine(line []byte) string { + trimmed := strings.TrimSpace(string(line)) + if trimmed == "" { + return "" + } + var ev agyStreamEvent + if !strings.HasPrefix(trimmed, "{") || json.Unmarshal([]byte(trimmed), &ev) != nil || ev.Event == "" { + r.emit(trimmed + "\n") + return "" + } + switch ev.Event { + case "init": + return ev.ConversationID + case "step_update": + if ev.StepUpdate == nil { + return "" + } + r.handleStep(ev.StepUpdate) + return ev.StepUpdate.ConversationID + case "result": + if ev.Result == nil { + return "" + } + r.mu.Lock() + r.result = ev.Result + r.mu.Unlock() + return ev.Result.ConversationID + } + return ev.ConversationID +} + +func (r *agyTurnRenderer) handleStep(s *agyStepUpdate) { + switch s.StepType { + case "agent_response": + r.mu.Lock() + text := r.pending[s.StepIndex] + s.TextDelta + var complete string + if i := strings.LastIndex(text, "\n"); i >= 0 { + complete, text = text[:i+1], text[i+1:] + } + if s.State != "ACTIVE" && text != "" { + complete, text = complete+text+"\n", "" + } + if text == "" { + delete(r.pending, s.StepIndex) + } else { + r.pending[s.StepIndex] = text + } + r.mu.Unlock() + if complete != "" { + r.emit(complete) + } + case "tool": + name := s.ToolName + if name == "" && s.ToolInfo != nil { + name = s.ToolInfo.Name + } + switch s.State { + case "ACTIVE": + r.mu.Lock() + r.lastTool = name + r.mu.Unlock() + r.emit("▸ " + truncateStr(name+" "+summarizeAgyToolParams(s.ToolInfo), agyTurnMaxLineRunes) + "\n") + case "DONE": + r.mu.Lock() + r.lastTool = "" + r.mu.Unlock() + if s.ToolInfo != nil && strings.TrimSpace(s.ToolInfo.Output) != "" { + r.emit(indentAgyToolOutput(s.ToolInfo.Output)) + } + default: + r.mu.Lock() + r.lastTool = "" + r.mu.Unlock() + r.emit(fmt.Sprintf("▸ %s %s\n", name, strings.ToLower(s.State))) + } + } +} + +// summarizeAgyToolParams renders tool parameters as one short line, with +// CommandLine (run_command) first since it is the one operators read most. +func summarizeAgyToolParams(info *agyToolInfo) string { + if info == nil || len(info.Parameters) == 0 { + return "" + } + if cmd, ok := info.Parameters["CommandLine"].(string); ok && cmd != "" { + return oneLine(cmd) + } + keys := make([]string, 0, len(info.Parameters)) + for k := range info.Parameters { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + v, err := json.Marshal(info.Parameters[k]) + if err != nil { + continue + } + parts = append(parts, k+"="+oneLine(string(v))) + } + return strings.Join(parts, " ") +} + +func indentAgyToolOutput(output string) string { + lines := strings.Split(strings.TrimRight(strings.ReplaceAll(output, "\r\n", "\n"), "\n"), "\n") + var b strings.Builder + for i, l := range lines { + if i == agyTurnToolOutputLines { + fmt.Fprintf(&b, " … %d more lines\n", len(lines)-i) + break + } + b.WriteString(" ") + b.WriteString(truncateStr(strings.TrimRight(l, "\r"), agyTurnMaxLineRunes)) + b.WriteString("\n") + } + return b.String() +} + +func oneLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} diff --git a/agent/agy_turn_test.go b/agent/agy_turn_test.go new file mode 100644 index 0000000..832f201 --- /dev/null +++ b/agent/agy_turn_test.go @@ -0,0 +1,360 @@ +package agent + +// Tests for `hive agy-turn` (agy_turn.go), the per-kick runner behind the +// headless agy shim. A fake agy script stands in for the real CLI: it records +// its argv and stdin per call and prints canned stream-json shaped like agy +// 1.2.9's (captured live on a hive pod). + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +const fakeAgyScript = `#!/bin/sh +d="$FAKE_AGY_DIR" +n=$(ls "$d" | grep -c '^args\.') +n=$((n+1)) +printf '%s\n' "$@" > "$d/args.$n" +cat > "$d/stdin.$n" +id="${FAKE_AGY_ID:-conv-1}" +status="${FAKE_AGY_STATUS:-SUCCESS}" +cat <128 KiB argv would +// fail with E2BIG), and the second kick resumes the conversation the first one +// started — by explicit id, never by agy's shared-HOME "most recent". +func TestRunAgyTurn_PromptOnStdinAndConversationContinuity(t *testing.T) { + fake := newFakeAgy(t) + dir := t.TempDir() + promptFile := filepath.Join(dir, "prompt.txt") + prompt := strings.Repeat("filler ", 30000) + "\nquote ' and \"double\" and $(not a subshell)\n" + if err := os.WriteFile(promptFile, []byte(prompt), 0o644); err != nil { + t.Fatal(err) + } + convFile := filepath.Join(dir, "conversation") + + turn := func(extra ...string) (int, string) { + args := append([]string{"--agy", fake.binary, "--model", "gemini-3.6-flash-low", "--effort", "low", + "--prompt-file", promptFile, "--conversation-file", convFile}, extra...) + code, out, stderr := runAgyTurnForTest(t, args...) + if stderr != "" { + t.Logf("stderr: %s", stderr) + } + return code, out + } + + if code, _ := turn(); code != 0 { + t.Fatalf("first turn exit = %d, want 0", code) + } + args := fake.args(1) + if _, ok := argValue(args, "--conversation"); ok { + t.Fatalf("first turn after launch must start a new conversation; args: %q", args) + } + for _, want := range [][2]string{{"--model", "gemini-3.6-flash-low"}, {"--effort", "low"}, {"--input-format", "stream-json"}, {"--output-format", "stream-json"}} { + if v, _ := argValue(args, want[0]); v != want[1] { + t.Errorf("agy %s = %q, want %q; args: %q", want[0], v, want[1], args) + } + } + if args[0] != "--dangerously-skip-permissions" { + t.Errorf("agy must run with --dangerously-skip-permissions first; args: %q", args) + } + for _, a := range args { + if strings.Contains(a, "filler") { + t.Fatalf("prompt text leaked into agy argv") + } + } + + var in agyStreamInput + raw := fake.stdin(1) + if bytes.Count(raw, []byte("\n")) != 1 || !bytes.HasSuffix(raw, []byte("\n")) { + t.Fatalf("stdin must be exactly one NDJSON line (agy runs one turn per line)") + } + if err := json.Unmarshal(raw, &in); err != nil { + t.Fatalf("stdin is not a stream-json message: %v", err) + } + if in.Event != "user" || in.Message.Role != "user" || in.Message.Content != prompt { + t.Fatalf("stdin message = event %q role %q content-equal %v; want the user prompt verbatim", + in.Event, in.Message.Role, in.Message.Content == prompt) + } + + if got := readAgyConversationID(convFile); got != "conv-1" { + t.Fatalf("conversation file = %q, want conv-1", got) + } + + if code, _ := turn(); code != 0 { + t.Fatalf("second turn exit = %d, want 0", code) + } + if v, _ := argValue(fake.args(2), "--conversation"); v != "conv-1" { + t.Fatalf("second turn must resume conv-1; args: %q", fake.args(2)) + } + + t.Setenv("FAKE_AGY_ID", "conv-2") + if code, _ := turn("--new-conversation"); code != 0 { + t.Fatalf("new-conversation turn exit = %d, want 0", code) + } + if _, ok := argValue(fake.args(3), "--conversation"); ok { + t.Fatalf("--new-conversation must not resume; args: %q", fake.args(3)) + } + if got := readAgyConversationID(convFile); got != "conv-2" { + t.Fatalf("conversation file after a new conversation = %q, want conv-2", got) + } +} + +// TestRunAgyTurn_UnresumableConversationIsReplaced: agy silently starts a new +// conversation when --conversation names one it cannot load (verified live +// with a made-up id). The runner must follow it to the new id and say so. +func TestRunAgyTurn_UnresumableConversationIsReplaced(t *testing.T) { + fake := newFakeAgy(t) + dir := t.TempDir() + promptFile := filepath.Join(dir, "prompt.txt") + _ = os.WriteFile(promptFile, []byte("hi"), 0o644) + convFile := filepath.Join(dir, "conversation") + _ = os.WriteFile(convFile, []byte("gone-id\n"), 0o600) + t.Setenv("FAKE_AGY_ID", "fresh-id") + + code, out, _ := runAgyTurnForTest(t, "--agy", fake.binary, "--prompt-file", promptFile, "--conversation-file", convFile) + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + if v, _ := argValue(fake.args(1), "--conversation"); v != "gone-id" { + t.Fatalf("runner must try the recorded conversation first; args: %q", fake.args(1)) + } + if got := readAgyConversationID(convFile); got != "fresh-id" { + t.Fatalf("conversation file = %q, want fresh-id", got) + } + if !strings.Contains(out, "gone-id could not be resumed") { + t.Errorf("a lost conversation must be visible in the pane; out: %q", out) + } +} + +// TestRunAgyTurn_RendersReadablePane pins what the pane shows: assistant text +// and tool calls as plain lines, the running marker as the last line while the +// turn runs, and a summary with no running marker after it once it ends. +func TestRunAgyTurn_RendersReadablePane(t *testing.T) { + fake := newFakeAgy(t) + dir := t.TempDir() + promptFile := filepath.Join(dir, "prompt.txt") + _ = os.WriteFile(promptFile, []byte("hi"), 0o644) + + code, out, _ := runAgyTurnForTest(t, "--agy", fake.binary, "--prompt-file", promptFile) + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + for _, want := range []string{ + "▸ run_command gh pr list --limit 5\n", + " one\n two\n three\n … 2 more lines\n", + "You asked me to remember **PELICAN**.\n", + "Done now.\n", + "not json from agy\n", + "HIVE agy turn: status=SUCCESS turns=2 duration=40.8s tokens=15703 conversation=conv-1\n", + } { + if !strings.Contains(out, want) { + t.Errorf("pane output missing %q; out:\n%s", want, out) + } + } + if strings.Contains(out, `"event"`) { + t.Errorf("raw stream-json must not reach the pane; out:\n%s", out) + } + if !strings.Contains(out, agyHeadlessRunningMarker) { + t.Fatalf("running marker never shown; out:\n%s", out) + } + final := out[strings.LastIndex(out, "\r\x1b[K")+len("\r\x1b[K"):] + if strings.Contains(final, agyHeadlessRunningMarker) { + t.Errorf("after the turn ends the running marker must be cleared so the pane reads idle; tail: %q", final) + } + if !paneShowsAgentWorking("HIVE agy ...\n" + agyHeadlessRunningMarker + " 5s · run_command") { + t.Errorf("the runner's status line must satisfy paneShowsAgentWorking") + } +} + +func TestRunAgyTurn_ExitCodes(t *testing.T) { + fake := newFakeAgy(t) + dir := t.TempDir() + promptFile := filepath.Join(dir, "prompt.txt") + _ = os.WriteFile(promptFile, []byte("hi"), 0o644) + run := func() (int, string) { + code, out, _ := runAgyTurnForTest(t, "--agy", fake.binary, "--prompt-file", promptFile) + return code, out + } + + t.Setenv("FAKE_AGY_STATUS", "ERROR") + t.Setenv("FAKE_AGY_ERROR", "quota exhausted") + if code, out := run(); code != 1 || !strings.Contains(out, "HIVE agy turn error: quota exhausted") { + t.Errorf("an ERROR result must fail the turn and show the error; code=%d out=%q", code, out) + } + t.Setenv("FAKE_AGY_STATUS", "SUCCESS") + t.Setenv("FAKE_AGY_RC", "3") + if code, _ := run(); code != 3 { + t.Errorf("agy's own exit code must pass through; got %d", code) + } + + if code, _, stderr := runAgyTurnForTest(t, "--agy", filepath.Join(dir, "missing-agy"), "--prompt-file", promptFile); code != 1 || !strings.Contains(stderr, "starting") { + t.Errorf("a missing agy binary must fail loudly; code=%d stderr=%q", code, stderr) + } + if code, _, _ := runAgyTurnForTest(t, "--agy", fake.binary); code != 2 { + t.Errorf("missing --prompt-file must be a usage error; got %d", code) + } +} + +func TestValidAgyConversationID(t *testing.T) { + for _, ok := range []string{"5eb55e60-385e-43ed-9bde-ab004281227c", "conv_1"} { + if !validAgyConversationID(ok) { + t.Errorf("%q should be accepted", ok) + } + } + for _, bad := range []string{"", "-p", "--continue", "a b", "a\nb", "a;rm", strings.Repeat("a", 129)} { + if validAgyConversationID(bad) { + t.Errorf("%q must be refused: it is passed to agy as an argument", bad) + } + } +} + +// TestAgyHeadlessLaunchShim_ExportsEnvAndConversationFile runs the real launch +// line and two kick lines in bash, with a stand-in for `hive agy-turn`. It pins +// (a) the per-agent env prefix is exported into the pane shell — bash drops +// assignments that prefix a builtin, so `KEY=v export PS1=...` lost them — and +// (b) every kick in one launch gets the same agent-private conversation file, +// while a relaunch gets a new one. +func TestAgyHeadlessLaunchShim_ExportsEnvAndConversationFile(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash not available") + } + dir := t.TempDir() + runner := filepath.Join(dir, "hive") + script := "#!/bin/sh\necho \"RUNNER mode=$HIVE_AGENT_MODE proxy=$HTTPS_PROXY args=$*\"\n" + if err := os.WriteFile(runner, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("TMPDIR", dir) + t.Setenv("HIVE_AGENT_MODE", "stale-from-session") + + launch := agyHeadlessFullLaunchCmd( + shellEnvVar("HIVE_AGENT_MODE", "issues-only")+" "+shellEnvVar("HTTPS_PROXY", "http://127.0.0.1:18443")+" ", + agyHeadlessLaunchCmd()) + kick := agyHeadlessTurnShellCommand(runner, "agy", "", "", "/tmp/p", false) + line := launch + "\n" + kick + "\n" + kick + "\n" + launch + "\n" + kick + "\n" + + out, err := exec.Command(bash, "--noprofile", "--norc", "-c", line).CombinedOutput() + if err != nil { + t.Fatalf("shim failed: %v\n%s", err, out) + } + var files []string + for _, l := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(l, "RUNNER ") { + continue + } + if !strings.Contains(l, "mode=issues-only proxy=http://127.0.0.1:18443") { + t.Errorf("kick did not see the launch's env prefix: %q", l) + } + f, ok := argValue(strings.Fields(l), "--conversation-file") + if !ok || !strings.HasPrefix(f, filepath.Join(dir, "hive-agy-conversation.")) { + t.Fatalf("kick got no per-launch conversation file: %q", l) + } + files = append(files, f) + } + if len(files) != 3 { + t.Fatalf("want 3 runner calls, got %d:\n%s", len(files), out) + } + if files[0] != files[1] { + t.Errorf("kicks within one launch must share a conversation file: %q vs %q", files[0], files[1]) + } + if files[2] == files[0] { + t.Errorf("a relaunch must start a new conversation file, got the old one %q", files[0]) + } + if _, err := os.Stat(files[0]); !os.IsNotExist(err) { + t.Errorf("a relaunch must remove the previous launch's conversation file %q (err=%v)", files[0], err) + } + if _, err := os.Stat(files[2]); !os.IsNotExist(err) { + t.Errorf("the shell's EXIT trap must remove the conversation file %q (err=%v)", files[2], err) + } +} diff --git a/agent/antigravity_credential_test.go b/agent/antigravity_credential_test.go new file mode 100644 index 0000000..5d88315 --- /dev/null +++ b/agent/antigravity_credential_test.go @@ -0,0 +1,101 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" +) + +// Regression coverage for kubestellar/hive#5734. +// +// Antigravity (`agy`) keeps its OAuth session one directory deeper than every +// other CLI's credential: .gemini/antigravity-cli/antigravity-oauth-token +// rather than .gemini/. Two protections were built on the assumption of +// depth 1 and neither reached it: +// +// - the entrypoint's inotify guard watched /data/home/.gemini/ without -r, +// and inotifywait reports events only for entries DIRECTLY inside the +// watched directory, so a write to the token was invisible to it. Measured +// on a live hive: sixteen minutes and one token rewrite after boot, the +// .claude guard's inotifywait pid had advanced (its credential sits at +// depth 1) while .gemini's was still the boot pid. +// - /data/home/.gemini was absent from WatchedHomeDirs entirely, so there was +// no Go-side fallback at all. +// +// That left the 5-minute polling sweep as the single mechanism protecting a +// shared credential — and #5730 is the record of that sweep dying silently. +// The entrypoint half is fixed in src/deploy/entrypoint.sh (and asserted by +// src/deploy/test_entrypoint_perm_guard.sh); this file pins the Go half. + +// TestWatchedHomeDirsIncludesGemini pins the fallback's existence against the +// production list, the same way TestWatchedHomeDirsIncludesBob does. It reads +// productionWatchedHomeDirs — the snapshot TestMain takes before repointing +// WatchedHomeDirs at the hermetic temp tree — so the pin stays on the real +// default rather than a test override. +func TestWatchedHomeDirsIncludesGemini(t *testing.T) { + const geminiHome = "/data/home/.gemini" + for _, dir := range productionWatchedHomeDirs { + if dir == geminiHome { + return + } + } + t.Fatalf("WatchedHomeDirs must contain %q so antigravity's shared credential has a "+ + "Go-side fallback; got %v", geminiHome, productionWatchedHomeDirs) +} + +// TestPermissionsWatcher_AntigravityNestedCredential is the depth test. A +// fallback that only reached the top of .gemini would repeat the entrypoint's +// mistake in Go: the token the fleet shares lives one level down, so the walk +// has to get there and the credential carve-out has to fire when it does. +func TestPermissionsWatcher_AntigravityNestedCredential(t *testing.T) { + resetPermWarnDedupe() + dir := t.TempDir() + geminiRoot := filepath.Join(dir, ".gemini") + tokenDir := filepath.Join(geminiRoot, "antigravity-cli") + token := filepath.Join(tokenDir, "antigravity-oauth-token") + + if err := os.MkdirAll(tokenDir, 0o700); err != nil { + t.Fatal(err) + } + // 0600 owned by the signing-in agent is what agy leaves behind on refresh. + if err := os.WriteFile(token, []byte("token-material"), 0o600); err != nil { + t.Fatal(err) + } + + origW, origG := WatchedHomeDirs, GooseLogsDir + WatchedHomeDirs = []string{geminiRoot} + GooseLogsDir = filepath.Join(dir, "goose", "logs") + t.Cleanup(func() { WatchedHomeDirs = origW; GooseLogsDir = origG }) + + ensureWatchedDirs(discardLogger()) + fixPermissions(discardLogger()) + + fi, err := os.Stat(token) + if err != nil { + t.Fatalf("stat token: %v", err) + } + if fi.Mode().Perm()&0o040 == 0 { + t.Fatalf("nested antigravity token left at %v — every agent uid but the one that "+ + "signed in is still locked out", fi.Mode().Perm()) + } + // Read, not write: it is an OAuth token, and agy replaces it by rename. + if fi.Mode().Perm()&0o020 != 0 { + t.Errorf("nested token granted group write (%v); read is all the fleet needs", + fi.Mode().Perm()) + } + if fi.Mode().Perm()&0o007 != 0 { + t.Errorf("nested token granted world access (%v)", fi.Mode().Perm()) + } +} + +// TestSharedCredentialBasesCoversAntigravityToken keeps the basename the +// watcher recognises tied to the path the entrypoint repairs. If agy moves its +// token again — which is how this bug arrived — the two must not drift apart +// silently. +func TestSharedCredentialBasesCoversAntigravityToken(t *testing.T) { + const tokenPath = "/data/home/.gemini/antigravity-cli/antigravity-oauth-token" + if !sharedCredentialBases[filepath.Base(tokenPath)] { + t.Fatalf("%q is not in sharedCredentialBases, so the Go fallback walks past the "+ + "credential it exists to repair", filepath.Base(tokenPath)) + } +} diff --git a/agent/antigravity_onboarding_test.go b/agent/antigravity_onboarding_test.go new file mode 100644 index 0000000..436570f --- /dev/null +++ b/agent/antigravity_onboarding_test.go @@ -0,0 +1,58 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEntrypointRepairsAgyOnboardingFastPath(t *testing.T) { + body, err := os.ReadFile("../../deploy/entrypoint.sh") + if err != nil { + t.Fatalf("read entrypoint: %v", err) + } + script := string(body) + for _, want := range []string{ + "/data/home/.gemini/antigravity-cli/cache", + "hive_fix_shared_credential /data/home/.gemini/antigravity-cli/cache/onboarding.json", + } { + if !strings.Contains(script, want) { + t.Fatalf("entrypoint must include agy onboarding in fast permission repair; missing %q", want) + } + } +} + +func TestPermissionsWatcher_AntigravityOnboardingState(t *testing.T) { + resetPermWarnDedupe() + dir := t.TempDir() + geminiRoot := filepath.Join(dir, ".gemini") + cacheDir := filepath.Join(geminiRoot, "antigravity-cli", "cache") + onboarding := filepath.Join(cacheDir, agyOnboardingStateBase) + + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(onboarding, []byte(`{"theme":"dark"}`), 0o600); err != nil { + t.Fatal(err) + } + + origW, origG := WatchedHomeDirs, GooseLogsDir + WatchedHomeDirs = []string{geminiRoot} + GooseLogsDir = filepath.Join(dir, "goose", "logs") + t.Cleanup(func() { WatchedHomeDirs = origW; GooseLogsDir = origG }) + + ensureWatchedDirs(discardLogger()) + fixPermissions(discardLogger()) + + fi, err := os.Stat(onboarding) + if err != nil { + t.Fatalf("stat onboarding: %v", err) + } + if fi.Mode().Perm()&0o040 == 0 { + t.Fatalf("agy onboarding state left at %v — the next agent UID will see first-run onboarding", fi.Mode().Perm()) + } + if fi.Mode().Perm()&0o020 != 0 { + t.Errorf("agy onboarding state granted group write (%v); read is all the fleet needs", fi.Mode().Perm()) + } +} diff --git a/agent/antigravity_perm_guard_test.go b/agent/antigravity_perm_guard_test.go new file mode 100644 index 0000000..f55bc72 --- /dev/null +++ b/agent/antigravity_perm_guard_test.go @@ -0,0 +1,26 @@ +package agent + +import ( + "os" + "strings" + "testing" +) + +func TestEntrypointGeminiGuardIsBoundedToAntigravityCLI(t *testing.T) { + body, err := os.ReadFile("../../deploy/entrypoint.sh") + if err != nil { + t.Fatalf("read entrypoint: %v", err) + } + script := string(body) + want := "hive_guard_forever gemini /data/home/.gemini/antigravity-cli/ close_write,moved_to,create hive_fix_gemini_instant &" + if !strings.Contains(script, want) { + t.Fatalf("gemini guard must watch agy's bounded state directory non-recursively; missing %q", want) + } + bad := "hive_guard_forever gemini /data/home/.gemini/ close_write,moved_to,create hive_fix_gemini_instant -r" + if strings.Contains(script, bad) { + t.Fatalf("gemini guard still recursively watches .gemini, so agy's brain/ churn can crash-loop it") + } + if !strings.Contains(script, "HIVE_WATCH_ERROR=\"$(inotifywait") || !strings.Contains(script, "watcher exited on $_dir${_why}") { + t.Fatalf("inotify guard failures must preserve stderr in the retry warning") + } +} diff --git a/agent/audit.go b/agent/audit.go new file mode 100644 index 0000000..60fced0 --- /dev/null +++ b/agent/audit.go @@ -0,0 +1,79 @@ +package agent + +import ( + "github.com/hivecommons/crux" +) + +// The audit vocabulary this runtime emits — the lifecycle action names, the +// AuditSink interface and the Fields helper — is DEFINED in the crux root +// package, so policy-layer packages can emit audit events without +// importing this runtime package and the runtime does not import the +// governor-side pkg/agentaudit (which aliases the same names for the +// recorder). The governor-side vocabulary (lease stages, commit trailers, +// artifact links, FormatAuditDetail) is agentaudit's alone and is referenced +// there directly (hivecommons/hive#13536). +const ( + AuditAgentStarted = crux.AuditAgentStarted + AuditAgentStartFailed = crux.AuditAgentStartFailed + AuditAgentStopped = crux.AuditAgentStopped + AuditAgentKicked = crux.AuditAgentKicked + AuditAgentPaused = crux.AuditAgentPaused + AuditAgentResumed = crux.AuditAgentResumed + AuditAgentAdded = crux.AuditAgentAdded + AuditAgentRemoved = crux.AuditAgentRemoved + AuditAgentBackendSet = crux.AuditAgentBackendSet + AuditAgentModelSet = crux.AuditAgentModelSet + AuditToolApproval = crux.AuditToolApproval + + AuditCopilotTokenMissing = crux.AuditCopilotTokenMissing + AuditClaudeTokenMissing = crux.AuditClaudeTokenMissing +) + +// auditActorSystem attributes an event to the hive process itself rather than +// to a person. Matches the dashboard's pseudo-user convention (see +// auditPseudoUsers there): "system" entries never count as user engagement. +// Lifecycle events originate in the agent manager, which has no HTTP request +// and therefore no authenticated user, so they are all system-attributed; the +// human-vs-governor distinction is carried in the detail's trigger= field. +const auditActorSystem = "system" + +// AuditSink receives agent lifecycle events for durable, queryable recording. +// See agentaudit.AuditSink for the contract; the alias exists because it is +// the parameter type of SetAuditSink. +type AuditSink = crux.AuditSink + +// SetAuditSink installs the durable audit sink. Safe to leave unset: a nil +// sink makes every audit call a no-op, which is what unit tests and any +// non-dashboard embedding of the manager get. +func (m *Manager) SetAuditSink(sink AuditSink) { + m.auditSink.Store(&sink) +} + +// audit records a lifecycle event to the durable audit store, if one is +// installed. It is deliberately a no-op when no sink is set rather than an +// error: audit recording must never be able to fail an agent operation. +// +// Stored/read via atomic.Pointer rather than under m.mu for the same reason as +// isGatewayBackend and bobAPIKeyResolver: this is called from the launch path +// (launchInTmux via Start), which ALREADY holds m.mu.Lock(). Re-locking a +// non-reentrant RWMutex on the same goroutine would deadlock startup before +// MarkReady and crash-loop every spoke. +func (m *Manager) audit(action, agentName string, fields map[string]any) { + m.auditWithActor(auditActorSystem, action, agentName, fields) +} + +// auditWithActor is audit with an explicit responsible user, for the paths +// where an operator's identity is known (dashboard-initiated switches). +func (m *Manager) auditWithActor(actor, action, agentName string, fields map[string]any) { + p := m.auditSink.Load() + if p == nil || *p == nil { + return + } + (*p).Record(actor, action, agentName, fields) +} + +// auditFields builds a fields map from alternating key/value pairs; see +// crux.Fields. +func auditFields(kv ...any) map[string]any { + return crux.Fields(kv...) +} diff --git a/agent/audit_test.go b/agent/audit_test.go new file mode 100644 index 0000000..b338398 --- /dev/null +++ b/agent/audit_test.go @@ -0,0 +1,263 @@ +package agent + +import ( + "io" + "log/slog" + "strings" + "sync" + "testing" + + "github.com/hivecommons/crux/config" +) + +// recordedAudit is one captured audit event. +type recordedAudit struct { + Actor string + Action string + Agent string + Fields map[string]any +} + +// fakeAuditSink captures events for assertions. Concurrency-safe because the +// manager records from the launch path and from background goroutines. +type fakeAuditSink struct { + mu sync.Mutex + entries []recordedAudit +} + +func (f *fakeAuditSink) Record(actor, action, agentName string, fields map[string]any) { + f.mu.Lock() + defer f.mu.Unlock() + f.entries = append(f.entries, recordedAudit{Actor: actor, Action: action, Agent: agentName, Fields: fields}) +} + +func (f *fakeAuditSink) find(action string) *recordedAudit { + f.mu.Lock() + defer f.mu.Unlock() + for i := range f.entries { + if f.entries[i].Action == action { + return &f.entries[i] + } + } + return nil +} + +func (f *fakeAuditSink) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.entries) +} + +func testManagerWithSink(t *testing.T) (*Manager, *fakeAuditSink) { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + m := NewManager(map[string]config.AgentConfig{}, logger, ProjectContext{}) + sink := &fakeAuditSink{} + m.SetAuditSink(sink) + return m, sink +} + +// TestAuditNilSinkIsNoOp is the load-bearing one for every other package: the +// manager must work with no dashboard attached, which is how all unit tests +// and any non-dashboard embedding construct it. +func TestAuditNilSinkIsNoOp(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + m := NewManager(map[string]config.AgentConfig{}, logger, ProjectContext{}) + + // No sink ever installed. + m.audit(AuditAgentStarted, "nobody", auditFields("backend", "claude")) + m.auditWithActor("alice", AuditAgentStopped, "nobody", nil) + + // And explicitly installing a nil interface must also not panic. + m.SetAuditSink(nil) + m.audit(AuditAgentStartFailed, "nobody", auditFields("error", "boom")) +} + +// TestAuditLaunchFailureRecordsBackendModelAndError is the watsonx incident: +// an agent whose backend the image does not support must leave a durable, +// queryable record naming the backend, the model, and the error. +func TestAuditLaunchFailureRecordsBackendModelAndError(t *testing.T) { + m, sink := testManagerWithSink(t) + + m.AddAgent("ci-maintainer", config.AgentConfig{Backend: "watsonx", Model: "granite-3"}) + m.mu.Lock() + ap := m.agents["ci-maintainer"] + ap.LastError = "unknown backend: watsonx" + m.announceLaunchFailureInPane(ap, "backend watsonx did not launch") + m.mu.Unlock() + + e := sink.find(AuditAgentStartFailed) + if e == nil { + t.Fatalf("no %s event recorded; got %d entries", AuditAgentStartFailed, sink.count()) + } + if e.Agent != "ci-maintainer" { + t.Errorf("agent = %q, want ci-maintainer", e.Agent) + } + if e.Fields["backend"] != "watsonx" { + t.Errorf("backend = %v, want watsonx", e.Fields["backend"]) + } + if e.Fields["model"] != "granite-3" { + t.Errorf("model = %v, want granite-3", e.Fields["model"]) + } + if e.Fields["outcome"] != "failure" { + t.Errorf("outcome = %v, want failure", e.Fields["outcome"]) + } + errText, _ := e.Fields["error"].(string) + if !strings.Contains(errText, "unknown backend: watsonx") { + t.Errorf("error = %q, want it to contain the launch error text", errText) + } +} + +// TestAuditLaunchFailureUsesOverrides proves the audit reports what the agent +// will ACTUALLY launch with, not just its static config. +func TestAuditLaunchFailureUsesOverrides(t *testing.T) { + m, sink := testManagerWithSink(t) + + m.AddAgent("a1", config.AgentConfig{Backend: "claude", Model: "opus"}) + m.mu.Lock() + ap := m.agents["a1"] + ap.BackendOverride = "watsonx" + ap.ModelOverride = "granite-4" + ap.LastError = "unknown backend: watsonx" + m.announceLaunchFailureInPane(ap, "nope") + m.mu.Unlock() + + e := sink.find(AuditAgentStartFailed) + if e == nil { + t.Fatal("no launch failure event recorded") + } + if e.Fields["backend"] != "watsonx" || e.Fields["model"] != "granite-4" { + t.Errorf("got backend=%v model=%v, want the overrides watsonx/granite-4", + e.Fields["backend"], e.Fields["model"]) + } +} + +func TestAuditAddAndRemoveRecordMethodAndModel(t *testing.T) { + m, sink := testManagerWithSink(t) + + m.AddAgent("a1", config.AgentConfig{Backend: "copilot", Model: "gpt-5"}) + added := sink.find(AuditAgentAdded) + if added == nil { + t.Fatal("no agent_added event") + } + if added.Fields["backend"] != "copilot" || added.Fields["model"] != "gpt-5" { + t.Errorf("added: backend=%v model=%v, want copilot/gpt-5", added.Fields["backend"], added.Fields["model"]) + } + if added.Actor != auditActorSystem { + t.Errorf("actor = %q, want %q", added.Actor, auditActorSystem) + } + + m.RemoveAgent("a1") + removed := sink.find(AuditAgentRemoved) + if removed == nil { + t.Fatal("no agent_removed event") + } + if removed.Fields["backend"] != "copilot" || removed.Fields["model"] != "gpt-5" { + t.Errorf("removed: backend=%v model=%v, want copilot/gpt-5", + removed.Fields["backend"], removed.Fields["model"]) + } +} + +func TestAuditBackendChangeRecordsOldAndNew(t *testing.T) { + m, sink := testManagerWithSink(t) + m.AddAgent("a1", config.AgentConfig{Backend: "claude", Model: "opus"}) + + if err := m.SetBackendOverride("a1", "copilot"); err != nil { + t.Fatalf("SetBackendOverride: %v", err) + } + e := sink.find(AuditAgentBackendSet) + if e == nil { + t.Fatal("no agent_backend_changed event") + } + if e.Fields["previous_backend"] != "claude" { + t.Errorf("previous_backend = %v, want claude", e.Fields["previous_backend"]) + } + if e.Fields["backend"] != "copilot" { + t.Errorf("backend = %v, want copilot", e.Fields["backend"]) + } +} + +// TestAuditBackendChangeSkipsNoOp guards the volume decision: only real state +// transitions are audited, so config reloads re-applying the current value do +// not flood the ring buffer. +func TestAuditBackendChangeSkipsNoOp(t *testing.T) { + m, sink := testManagerWithSink(t) + m.AddAgent("a1", config.AgentConfig{Backend: "claude"}) + + if err := m.SetBackendOverride("a1", "claude"); err != nil { + t.Fatalf("SetBackendOverride: %v", err) + } + if e := sink.find(AuditAgentBackendSet); e != nil { + t.Errorf("no-op backend set should record nothing, got %+v", e.Fields) + } +} + +func TestAuditModelChangeRecordsOldAndNew(t *testing.T) { + m, sink := testManagerWithSink(t) + m.AddAgent("a1", config.AgentConfig{Backend: "claude", Model: "sonnet"}) + + if err := m.SetModelOverride("a1", "opus"); err != nil { + t.Fatalf("SetModelOverride: %v", err) + } + e := sink.find(AuditAgentModelSet) + if e == nil { + t.Fatal("no agent_model_changed event") + } + if e.Fields["previous_model"] != "sonnet" { + t.Errorf("previous_model = %v, want sonnet", e.Fields["previous_model"]) + } + if e.Fields["model"] != "opus" { + t.Errorf("model = %v, want opus", e.Fields["model"]) + } + if e.Fields["backend"] != "claude" { + t.Errorf("backend = %v, want claude", e.Fields["backend"]) + } +} + +func TestAuditModelChangeSkipsNoOp(t *testing.T) { + m, sink := testManagerWithSink(t) + m.AddAgent("a1", config.AgentConfig{Backend: "claude", Model: "opus"}) + + if err := m.SetModelOverride("a1", "opus"); err != nil { + t.Fatalf("SetModelOverride: %v", err) + } + if e := sink.find(AuditAgentModelSet); e != nil { + t.Errorf("no-op model set should record nothing, got %+v", e.Fields) + } +} + +func TestAuditPinModelRecordsChange(t *testing.T) { + m, sink := testManagerWithSink(t) + m.AddAgent("a1", config.AgentConfig{Backend: "claude", Model: "sonnet"}) + + if err := m.PinModel("a1", "opus"); err != nil { + t.Fatalf("PinModel: %v", err) + } + e := sink.find(AuditAgentModelSet) + if e == nil { + t.Fatal("no agent_model_changed event for pin") + } + if e.Fields["previous_model"] != "sonnet" || e.Fields["model"] != "opus" { + t.Errorf("got previous=%v model=%v, want sonnet -> opus", + e.Fields["previous_model"], e.Fields["model"]) + } + if e.Fields["trigger"] != "pin" { + t.Errorf("trigger = %v, want pin", e.Fields["trigger"]) + } +} + +func TestAuditFieldsDropsEmptyStringsAndOddKey(t *testing.T) { + f := auditFields("backend", "claude", "model", "", "count", 3, "dangling") + if _, ok := f["model"]; ok { + t.Error("empty-string value should be dropped so the UI has no noisy 'model='") + } + if f["backend"] != "claude" { + t.Errorf("backend = %v, want claude", f["backend"]) + } + if f["count"] != 3 { + t.Errorf("count = %v, want 3", f["count"]) + } + if _, ok := f["dangling"]; ok { + t.Error("odd trailing key should be dropped") + } +} diff --git a/agent/authorize_issue_open_test.go b/agent/authorize_issue_open_test.go new file mode 100644 index 0000000..0e24a1c --- /dev/null +++ b/agent/authorize_issue_open_test.go @@ -0,0 +1,108 @@ +package agent + +import ( + "strings" + "testing" + + "github.com/hivecommons/crux/config" +) + +// AuthorizeIssueOpen is the gate for the file-based issue-request relay +// (issue create / comment / claim). It must mirror AuthorizePROpen's +// forge-resistance (the request file's owner must BE the claimed agent) +// while enforcing the weaker CanCreateIssues mode gate. These tests pin +// both halves so a regression cannot silently let an advisory agent write +// issues or let a forged request file impersonate another agent. + +func issueOpenManager() *Manager { + m := testManager(6) + m.agents["advisor"] = &AgentProcess{Name: "advisor", Config: config.AgentConfig{Mode: "ADVISORY"}} + m.agents["issuer"] = &AgentProcess{Name: "issuer", Config: config.AgentConfig{Mode: "ISSUES_ONLY"}} + m.agents["writer"] = &AgentProcess{Name: "writer", Config: config.AgentConfig{Mode: "ISSUES_AND_PRS"}} + return m +} + +func TestAuthorizeIssueOpen_ModeGate(t *testing.T) { + m := issueOpenManager() + + for _, kind := range []string{"issue", "comment", "claim"} { + if err := m.AuthorizeIssueOpen("issuer", 0, kind); err != nil { + t.Errorf("ISSUES_ONLY agent must be allowed kind=%q: %v", kind, err) + } + if err := m.AuthorizeIssueOpen("writer", 0, kind); err != nil { + t.Errorf("ISSUES_AND_PRS agent must be allowed kind=%q: %v", kind, err) + } + err := m.AuthorizeIssueOpen("advisor", 0, kind) + if err == nil { + t.Errorf("ADVISORY agent must be denied kind=%q", kind) + } else if !strings.Contains(err.Error(), "may not create issues") { + t.Errorf("denial should cite the mode gate, got: %v", err) + } + } +} + +func TestAuthorizeIssueOpen_EmptyAgentName(t *testing.T) { + m := issueOpenManager() + for _, name := range []string{"", " "} { + if err := m.AuthorizeIssueOpen(name, 0, "issue"); err == nil { + t.Errorf("empty agent name %q must be denied", name) + } + } +} + +func TestAuthorizeIssueOpen_UnknownAgent(t *testing.T) { + m := issueOpenManager() + err := m.AuthorizeIssueOpen("ghost", 0, "issue") + if err == nil { + t.Fatal("unknown agent must be denied") + } + if !strings.Contains(err.Error(), "unknown agent") { + t.Errorf("denial should cite the unknown agent, got: %v", err) + } +} + +func TestAuthorizeIssueOpen_ForgeResistance(t *testing.T) { + m := issueOpenManager() + m.uidMap = NewUIDMap() + issuerUID := m.uidMap.AllocateUID("issuer") + writerUID := m.uidMap.AllocateUID("writer") + + // The file owner matching the claimed agent passes. + if err := m.AuthorizeIssueOpen("issuer", issuerUID, "issue"); err != nil { + t.Errorf("owner==agent must be allowed: %v", err) + } + + // A request claiming one agent but owned by another is a forgery. + err := m.AuthorizeIssueOpen("issuer", writerUID, "issue") + if err == nil { + t.Fatal("uid mismatch must be denied") + } + if !strings.Contains(err.Error(), "owned by agent") { + t.Errorf("denial should cite the owning agent, got: %v", err) + } + + // A UID not registered to any agent is denied outright. + if err := m.AuthorizeIssueOpen("issuer", 99999, "issue"); err == nil { + t.Error("unregistered uid must be denied") + } + + // The forge check happens BEFORE the mode gate: a forged file is + // rejected as a forgery even when the claimed agent could write issues. + err = m.AuthorizeIssueOpen("writer", issuerUID, "comment") + if err == nil || !strings.Contains(err.Error(), "owned by agent") { + t.Errorf("forge check must precede the mode gate, got: %v", err) + } +} + +func TestAuthorizeIssueOpen_NoUIDMapSkipsForgeCheck(t *testing.T) { + m := issueOpenManager() + // With no uidMap (or fileUID<=0) only the mode gate applies. + if err := m.AuthorizeIssueOpen("issuer", 12345, "issue"); err != nil { + t.Errorf("nil uidMap must skip forge check: %v", err) + } + m.uidMap = NewUIDMap() + m.uidMap.AllocateUID("issuer") + if err := m.AuthorizeIssueOpen("issuer", 0, "issue"); err != nil { + t.Errorf("fileUID=0 must skip forge check: %v", err) + } +} diff --git a/agent/authorize_push_branch_test.go b/agent/authorize_push_branch_test.go new file mode 100644 index 0000000..e4c5cf9 --- /dev/null +++ b/agent/authorize_push_branch_test.go @@ -0,0 +1,94 @@ +package agent + +import ( + "strings" + "testing" + + "github.com/hivecommons/crux/config" +) + +// AuthorizePushBranch is the gate for the file-based push-branch relay +// (hivecommons/hive#9771). It must mirror AuthorizePROpen exactly — the same +// file-UID forge-resistance and the same CanPush ACMM gate — so the relay +// grants no privilege over the direct `git push` it stands in for. These tests +// pin both halves. + +func pushBranchManager() *Manager { + m := testManager(6) + m.agents["advisor"] = &AgentProcess{Name: "advisor", Config: config.AgentConfig{Mode: "ADVISORY"}} + m.agents["issuer"] = &AgentProcess{Name: "issuer", Config: config.AgentConfig{Mode: "ISSUES_ONLY"}} + m.agents["writer"] = &AgentProcess{Name: "writer", Config: config.AgentConfig{Mode: "ISSUES_AND_PRS"}} + return m +} + +func TestAuthorizePushBranch_ModeGate(t *testing.T) { + m := pushBranchManager() + + if err := m.AuthorizePushBranch("writer", 0); err != nil { + t.Errorf("push-capable agent must be allowed: %v", err) + } + for _, name := range []string{"advisor", "issuer"} { + err := m.AuthorizePushBranch(name, 0) + if err == nil { + t.Errorf("%s must be denied: not push-capable", name) + } else if !strings.Contains(err.Error(), "not push-capable") { + t.Errorf("denial should cite the push gate, got: %v", err) + } + } +} + +// The gate must agree with AuthorizePROpen for every agent: both stand in for +// the same CanPush write tier, so an agent allowed one but not the other would +// be a policy split with no rationale. +func TestAuthorizePushBranch_MirrorsAuthorizePROpen(t *testing.T) { + m := pushBranchManager() + for name := range m.agents { + pr := m.AuthorizePROpen(name, 0) == nil + push := m.AuthorizePushBranch(name, 0) == nil + if pr != push { + t.Errorf("%s: AuthorizePROpen allowed=%v but AuthorizePushBranch allowed=%v", name, pr, push) + } + } +} + +func TestAuthorizePushBranch_RejectsEmptyAgentName(t *testing.T) { + m := pushBranchManager() + for _, name := range []string{"", " "} { + if err := m.AuthorizePushBranch(name, 5000); err == nil { + t.Errorf("AuthorizePushBranch(%q) = nil, want an error", name) + } + } +} + +// The core forge check: agent A may not push using a request file owned by +// agent B, and a file owned by an unregistered UID is refused outright. +func TestAuthorizePushBranch_ForgeResistance(t *testing.T) { + m := pushBranchManager() + m.uidMap = &UIDMap{BaseUID: 5000, Agents: map[string]int{ + "writer": 5000, + "issuer": 5001, + }} + + if err := m.AuthorizePushBranch("writer", 5000); err != nil { + t.Errorf("owner's own request denied: %v", err) + } + err := m.AuthorizePushBranch("writer", 5001) + if err == nil { + t.Fatal("a request file owned by another agent's UID was accepted") + } + if !strings.Contains(err.Error(), "issuer") { + t.Errorf("error = %q, want it to name the real owning agent", err) + } + if err := m.AuthorizePushBranch("writer", 4999); err == nil || + !strings.Contains(err.Error(), "unknown uid") { + t.Errorf("unregistered file owner accepted or misreported: %v", err) + } +} + +func TestAuthorizePushBranch_UnknownAgent(t *testing.T) { + m := pushBranchManager() + if err := m.AuthorizePushBranch("ghost", 0); err == nil || + !strings.Contains(err.Error(), "unknown agent") { + t.Errorf("unknown agent accepted or misreported: %v", err) + } +} diff --git a/agent/authorize_review_request_test.go b/agent/authorize_review_request_test.go new file mode 100644 index 0000000..03d0662 --- /dev/null +++ b/agent/authorize_review_request_test.go @@ -0,0 +1,127 @@ +package agent + +import ( + "strings" + "testing" + + "github.com/hivecommons/crux/config" +) + +// boolPtr is a local helper: AgentConfig.Converse is a *bool so that "unset" +// stays distinguishable from "explicitly false". +func boolPtr(b bool) *bool { return &b } + +// advisoryConverseAgent builds the shape AuthorizeReviewRequest exists for: +// mode ADVISORY (not push-capable) plus an explicit `converse: true`. +func advisoryConverseAgent(name string, converse *bool) *AgentProcess { + return &AgentProcess{Name: name, Config: config.AgentConfig{Converse: converse}} +} + +// TestAuthorizeReviewRequestAllowsAdvisoryConverseAgent is the whole point of +// the change: an ADVISORY agent with `converse: true` may submit a review +// through the sanctioned relay. Before this, the relay demanded CanPush() and +// an advisory reviewer could reach a verdict with no way to say it. +func TestAuthorizeReviewRequestAllowsAdvisoryConverseAgent(t *testing.T) { + m := testManager(acmmLevelAdvisoryOnly) + m.agents["reviewer"] = advisoryConverseAgent("reviewer", boolPtr(true)) + m.uidMap = &UIDMap{BaseUID: testUIDBase, Agents: map[string]int{"reviewer": testUIDBase}} + + if mode := m.agentMode(m.agents["reviewer"]); mode.CanPush() { + t.Fatalf("precondition: reviewer is push-capable (mode %s) at ACMM %d; "+ + "this test must exercise the NON-push path", mode, acmmLevelAdvisoryOnly) + } + if err := m.AuthorizeReviewRequest("reviewer", testUIDBase); err != nil { + t.Errorf("AuthorizeReviewRequest() = %v, want nil (converse grants conversation writes)", err) + } +} + +// TestAuthorizeReviewRequestRejectsAdvisoryWithoutConverse pins the other side: +// converse is opt-in, so an advisory agent that was never granted it stays +// silent. Without this, the change would read as "advisory agents may now +// review", which is not what it does. +func TestAuthorizeReviewRequestRejectsAdvisoryWithoutConverse(t *testing.T) { + for _, tc := range []struct { + name string + converse *bool + }{ + {"unset", nil}, + {"explicitly false", boolPtr(false)}, + } { + t.Run(tc.name, func(t *testing.T) { + m := testManager(acmmLevelAdvisoryOnly) + m.agents["reviewer"] = advisoryConverseAgent("reviewer", tc.converse) + m.uidMap = &UIDMap{BaseUID: testUIDBase, Agents: map[string]int{"reviewer": testUIDBase}} + + err := m.AuthorizeReviewRequest("reviewer", testUIDBase) + if err == nil { + t.Fatalf("AuthorizeReviewRequest() = nil, want an error (converse is opt-in)") + } + if !strings.Contains(err.Error(), "converse") { + t.Errorf("error = %q, want it to name the missing `converse` capability so an "+ + "operator knows which knob to turn", err) + } + }) + } +} + +// TestAuthorizeReviewRequestAllowsPushCapableWithoutConverse is the +// no-regression case: mode alone still authorizes, exactly as AuthorizePROpen +// did before. Capability is an ADDITIONAL grant path, not a replacement. +func TestAuthorizeReviewRequestAllowsPushCapableWithoutConverse(t *testing.T) { + m := testManager(acmmLevelPushCapable) + m.agents["quality"] = &AgentProcess{Name: "quality"} + m.uidMap = &UIDMap{BaseUID: testUIDBase, Agents: map[string]int{"quality": testUIDBase}} + + if mode := m.agentMode(m.agents["quality"]); !mode.CanPush() { + t.Fatalf("precondition: quality is not push-capable (mode %s) at ACMM %d", mode, acmmLevelPushCapable) + } + if err := m.AuthorizeReviewRequest("quality", testUIDBase); err != nil { + t.Errorf("AuthorizeReviewRequest() = %v, want nil (push-capable mode still authorizes)", err) + } +} + +// TestAuthorizeReviewRequestRejectsForgedAgentName keeps the forge-resistance +// the other authorizers enforce: converse must not become a way to review as +// somebody else. Agent A writes the file; agent B claims it. +func TestAuthorizeReviewRequestRejectsForgedAgentName(t *testing.T) { + m := testManager(acmmLevelAdvisoryOnly) + m.agents["reviewer"] = advisoryConverseAgent("reviewer", boolPtr(true)) + m.agents["scanner"] = advisoryConverseAgent("scanner", boolPtr(true)) + m.uidMap = &UIDMap{BaseUID: testUIDBase, Agents: map[string]int{ + "reviewer": testUIDBase, + "scanner": testUIDBase + 1, + }} + + // reviewer claims a request file actually owned by scanner. + err := m.AuthorizeReviewRequest("reviewer", testUIDBase+1) + if err == nil { + t.Fatal("AuthorizeReviewRequest() = nil, want an error (file is owned by scanner)") + } + if !strings.Contains(err.Error(), "owned by") { + t.Errorf("error = %q, want it to explain the ownership mismatch", err) + } +} + +// TestAuthorizeReviewRequestRejectsEmptyAndUnknownAgents covers the two +// fail-closed guards. +func TestAuthorizeReviewRequestRejectsEmptyAndUnknownAgents(t *testing.T) { + m := testManager(acmmLevelPushCapable) + + for _, name := range []string{"", " "} { + err := m.AuthorizeReviewRequest(name, testUIDBase) + if err == nil { + t.Fatalf("AuthorizeReviewRequest(%q) = nil, want an error", name) + } + if !strings.Contains(err.Error(), "no agent named") { + t.Errorf("AuthorizeReviewRequest(%q) error = %q, want it to mention the missing name", name, err) + } + } + + err := m.AuthorizeReviewRequest("ghost", 0) + if err == nil { + t.Fatal("AuthorizeReviewRequest(\"ghost\") = nil, want an error for an unregistered agent") + } + if !strings.Contains(err.Error(), "unknown agent") { + t.Errorf("error = %q, want it to say the agent is unknown", err) + } +} diff --git a/agent/authprobe.go b/agent/authprobe.go new file mode 100644 index 0000000..547902b --- /dev/null +++ b/agent/authprobe.go @@ -0,0 +1,415 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/hivecommons/crux" + "github.com/hivecommons/crux/claude" + "github.com/hivecommons/crux/config" +) + +// BackendRequiresInteractiveAuth is crux.BackendRequiresInteractiveAuth; the +// table lives in the root so the dashboard decides whether to show a login +// badge without importing the runtime. +func BackendRequiresInteractiveAuth(backend string) bool { + return crux.BackendRequiresInteractiveAuth(backend) +} + +// AgentHome returns the HOME directory an agent's CLI runs with. It mirrors +// exactly what buildAgentEnv exports as HOME on the launch path, so the auth +// probe reads the same filesystem location the CLI writes to. Agents with no +// allocated UID (UID == 0) run as the hive user out of the process HOME. +// +// Per-UID interactive agents get a PER-AGENT home (#4596 — the shared +// /data/home let any agent's atomic .claude.json rewrite sign out the whole +// fleet); inference agents keep their /tmp-based per-agent home; the +// HIVE_SHARED_AGENT_HOME=1 escape hatch restores the legacy shared layout. +func AgentHome(agentName string, uid int, backend string) string { + if uid <= 0 { + if h := os.Getenv("HOME"); h != "" { + return h + } + return sharedAgentHome + } + if IsInferenceBackend(backend) { + return inferenceHomePath(agentName) + } + if sharedAgentHomeForced() { + return sharedAgentHome + } + return interactiveHomePath(agentName) +} + +// agentClaudeCredentialPaths returns the .credentials.json locations to try for +// one agent, agent-own home FIRST (per-UID layout) then the shared legacy path. +// Ordering matters: the per-UID file is the one the agent's CLI actually writes. +func agentClaudeCredentialPaths(agentName string, uid int, backend string) []string { + paths := []string{} + if home := AgentHome(agentName, uid, backend); home != "" { + paths = append(paths, filepath.Join(home, ".claude", ".credentials.json")) + } + if !containsPath(paths, sharedClaudeCredentialPath) { + paths = append(paths, sharedClaudeCredentialPath) + } + return paths +} + +func containsPath(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} + +// ClaudeCredentialCandidatePaths returns every .credentials.json worth trying +// for a BACKEND-level "is claude authenticated on this hive?" question — +// per-UID agent homes first, the shared legacy path last. +// +// It exists so that question has exactly ONE answer (#4699). The per-UID fix +// this file's header describes was applied here and NOT to the dashboard's +// model-discovery probe, which kept stat'ing only the shared path. On a hive +// where the two disagree the operator sees a self-contradicting UI: the agent +// card reads "✓ logged in" from this probe while every model in the dropdown +// reads "(common alias, unverified)" from that one. Both now resolve paths +// through this function, so they cannot drift apart again. +// +// Backend-level, so ANY agent's fresh token answers it: a model catalog is a +// property of the provider, not of the agent that asked. Ordering is +// deterministic (agents sorted by name) because m.agents iterates randomly and +// a probe that consulted a different credential on each call would be +// untestable and would make an expired token an intermittent failure. +// +// A nil Manager yields the shared path alone, which is the pre-per-UID +// behavior — callers without an agent manager are no worse off than before. +func (m *Manager) ClaudeCredentialCandidatePaths() []string { + if m == nil { + return []string{sharedClaudeCredentialPath} + } + + type agentRef struct { + name string + backend string + uid int + } + m.mu.RLock() + refs := make([]agentRef, 0, len(m.agents)) + for name, proc := range m.agents { + if proc == nil { + continue + } + backend := proc.Config.Backend + if proc.BackendOverride != "" { + backend = proc.BackendOverride + } + if backend != "claude" { + continue + } + refs = append(refs, agentRef{name: name, backend: backend, uid: proc.UID}) + } + m.mu.RUnlock() + sort.Slice(refs, func(i, j int) bool { return refs[i].name < refs[j].name }) + + paths := []string{} + for _, r := range refs { + for _, p := range agentClaudeCredentialPaths(r.name, r.uid, r.backend) { + if !containsPath(paths, p) { + paths = append(paths, p) + } + } + } + // Always reachable even with no claude agents registered: a hive that has + // not started its agents yet still has a shared login worth checking. + if !containsPath(paths, sharedClaudeCredentialPath) { + paths = append(paths, sharedClaudeCredentialPath) + } + return paths +} + +// agentCopilotConfigPaths returns the Copilot credential locations to try for +// one agent: the per-UID config.json and token dir first, then the shared path. +func agentCopilotConfigPaths(agentName string, uid int, backend string) []string { + paths := []string{} + if home := AgentHome(agentName, uid, backend); home != "" { + paths = append(paths, + filepath.Join(home, ".copilot", "config.json"), + filepath.Join(home, ".config", "github-copilot", "apps.json"), + filepath.Join(home, ".config", "github-copilot", "hosts.json"), + ) + } + if !containsPath(paths, sharedCopilotConfigPath) { + paths = append(paths, sharedCopilotConfigPath) + } + return paths +} + +// agentCodexAuthPaths returns the auth.json locations to try for one Codex +// agent: the per-agent CODEX_HOME first, then the shared login location. +func agentCodexAuthPaths(agentName string, uid int, backend string) []string { + paths := []string{} + if uid > 0 { + paths = append(paths, filepath.Join(codexHomePath(agentName), "auth.json")) + } else if home := AgentHome(agentName, uid, backend); home != "" { + paths = append(paths, filepath.Join(home, ".codex", "auth.json")) + } + if !containsPath(paths, codexSharedAuthFile) { + paths = append(paths, codexSharedAuthFile) + } + return paths +} + +func codexAuthFileHasCredentials(path string) bool { + b, err := os.ReadFile(path) + if err != nil { + return false + } + var data map[string]any + if err := json.Unmarshal(b, &data); err != nil { + return false + } + nonEmpty := func(v any) bool { + s, ok := v.(string) + return ok && strings.TrimSpace(s) != "" + } + if tokens, ok := data["tokens"].(map[string]any); ok { + for _, key := range []string{"access_token", "refresh_token", "id_token"} { + if nonEmpty(tokens[key]) { + return true + } + } + } + for _, key := range []string{"OPENAI_API_KEY", "api_key", "openai_api_key"} { + if nonEmpty(data[key]) { + return true + } + } + return false +} + +func codexEnvHasCredentials() bool { + return strings.TrimSpace(os.Getenv("CODEX_API_KEY")) != "" || + strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) != "" +} + +// AgentAuthState reports the auth state for ONE agent, applying the precedence +// rule documented at the top of this file. `known == false` means "no opinion" +// and the dashboard must not render a login badge. +// +// running is true when the agent's process state is running (the same signal +// /api/health/deep folds into `pass`); needsLogin is the pane poller's +// observation that the terminal is literally showing a login prompt. +func (m *Manager) AgentAuthState(agentName string, uid int, backend string, running, needsLogin bool) (available, known bool) { + // (1) METHOD GATE — no interactive login exists for this backend, so there + // is nothing an operator could do and a badge is always a false alarm. + if !BackendRequiresInteractiveAuth(backend) { + if backend == bobBackend { + // bob's only credential is its API key; its presence is a complete + // answer either way. + return m.bobAPIKey() != "", true + } + if config.IsInferenceBackend(backend) { + // Inference backends carry their key in config/proxy routing. + // Authenticated by construction as far as the login badge cares. + return true, true + } + // Backend we cannot introspect and that has no login flow: no opinion. + return false, false + } + + // (3) TRUE LOGIN SIGNAL — the pane literally shows a login prompt. This is + // direct observation of the agent's own terminal and outranks any file. + if needsLogin { + return false, true + } + + // (2) POSITIVE EVIDENCE beats absence-of-file. A running agent that is not + // at a login prompt is working; a missing credentials file must not + // reclassify it as "needs login". Report unknown → no badge. + if running { + return false, false + } + + // (4) FILE PROBE, agent-own home first, shared legacy path second. The + // POSITIVE half lives in credentialFileProves so the login detector can ask + // the same question without inheriting rules 1-3 (#5291). + proven := m.credentialFileProves(agentName, uid, backend) + switch backend { + case "claude": + if proven { + return true, true + } + // A found+valid token is positive proof (above). Its ABSENCE, however, is + // NOT proof of "needs login" for Claude: unlike copilot/codex, Claude can + // be authenticated with NO credentials file on disk — a live in-memory + // session, or credentials under a home this probe cannot read (per-UID + // HOME, keychain). Reporting `known=true` here painted the amber 🔑 + // needs-login badge on Claude agents that were demonstrably authenticated + // and mid-work — the false positive was hit whenever such an agent's + // process state flapped to not-running (e.g. a frequently-restarting + // agent) so the (2) running-guard above didn't fire. Report UNKNOWN + // (no opinion → no badge) instead: a missing Claude cred file is + // inconclusive, and inventing a login prompt that does not exist is worse + // than showing nothing. A REAL Claude login prompt is still caught by the + // pane-scan needsLogin signal at (3), which outranks this. + return false, false + case "copilot": + if proven { + return true, true + } + return false, true + case "codex": + if proven { + return true, true + } + return false, true + default: + // gemini and any other interactive backend: we have no reliable probe, + // so express no opinion rather than inventing a false "needs login". + return false, false + } +} + +// AgentAuthAvailable is the per-agent provider the dashboard registers. It +// resolves the agent's live process state itself so callers only need a name. +func (m *Manager) AgentAuthAvailable(agentName string) (available, known bool) { + m.mu.RLock() + proc := m.agents[agentName] + m.mu.RUnlock() + if proc == nil { + return false, false + } + backend := proc.Config.Backend + if proc.BackendOverride != "" { + backend = proc.BackendOverride + } + proc.paneMu.RLock() + needsLogin := proc.NeedsLogin + proc.paneMu.RUnlock() + return m.AgentAuthState(agentName, proc.UID, backend, proc.State == StateRunning, needsLogin) +} + +// AgentCredentialRefreshable reports whether this agent's Claude credential can +// put it back to work without a human login — claude.HasUsableToken over the +// agent's own credential paths, the same question the restart-heal gates ask. +// known is false for every other backend: ADR-0024 records no verified +// idle-refresh path for them, so hive does not claim one. +func (m *Manager) AgentCredentialRefreshable(agentName string) (refreshable, known bool) { + if m == nil { + return false, false + } + m.mu.RLock() + proc := m.agents[agentName] + m.mu.RUnlock() + if proc == nil { + return false, false + } + backend := proc.Config.Backend + if proc.BackendOverride != "" { + backend = proc.BackendOverride + } + if backend != "claude" { + return false, false + } + return m.credentialFileProves(agentName, proc.UID, backend), true +} + +// credentialFileProves answers ONLY the positive half of the file probe: is +// there, right now, on-disk (or in-process) evidence that this agent's backend +// is authenticated? +// +// It is deliberately one-directional. `false` means "no proof", NOT "logged +// out" — Claude in particular can be authenticated with no credentials file +// this process can read (a live in-memory session, a per-UID HOME, a keychain), +// which is why AgentAuthState reports UNKNOWN rather than needs-login when this +// comes back false for claude. +// +// Split out of AgentAuthState for kubestellar/hive#5291. The login detector +// needs this question and must NOT get AgentAuthState's answer, whose +// precedence rules are built for a dashboard badge: rule 2 short-circuits to +// "unknown" for any RUNNING agent (the detector only ever scans running +// agents), and rule 3 lets the pane's own login text outrank the credential +// file (which is precisely the evidence the detector must not trust on its +// own). Sharing the code rather than copying it keeps the two answers from +// drifting the way #4699 describes. +func (m *Manager) credentialFileProves(agentName string, uid int, backend string) bool { + switch backend { + case "claude": + for _, p := range agentClaudeCredentialPaths(agentName, uid, backend) { + if claude.HasUsableToken(p) { + return true + } + } + case "copilot": + if m != nil { + m.mu.RLock() + tok := m.copilotAuthToken + m.mu.RUnlock() + if tok != "" { + return true + } + } + if _, err := os.Stat(copilotUserTokenProbePath); err == nil { + return true + } + for _, p := range agentCopilotConfigPaths(agentName, uid, backend) { + if copilotCredentialFileHasTokens(p) { + return true + } + } + case "codex": + if codexEnvHasCredentials() { + return true + } + for _, p := range agentCodexAuthPaths(agentName, uid, backend) { + if codexAuthFileHasCredentials(p) { + return true + } + } + } + return false +} + +// AgentHasValidCredential reports whether this agent's backend is DEMONSTRABLY +// authenticated right now (kubestellar/hive#5291). +// +// Positive evidence only, and that asymmetry is the whole point. The login +// detector uses it to decide when NOT to pause: proof of a working credential +// means a login prompt on screen is residue or a stuck CLI, which is the +// token-restart heal's job, not an operator's. Anything less than proof — +// including "we cannot check this backend at all" — returns false and leaves +// the detector's existing behaviour untouched. +// +// One honest limitation: only claude's credential carries an expiry this can +// verify (claude.HasUsableToken). Copilot and codex are checked for the +// PRESENCE of tokens, so a stale-but-present copilot token reads as valid here. +// That is the same trade the manager's own token-restart heal already makes in +// configHasTokens(), and it fails in the safe direction for this caller: the +// heal restarts the CLI, which is a recovery attempt, rather than the detector +// pausing the agent, which is not. +// +// For claude the question asked is "can a restart still use this?", not "is +// the access token live right now?". An access token that has aged out under a +// long-running session leaves a refresh grant that the next CLI start redeems, +// so proof survives a routine expiry — which is the state a busy hive spends +// part of every day in. +func (m *Manager) AgentHasValidCredential(agentName string) bool { + if m == nil { + return false + } + m.mu.RLock() + proc := m.agents[agentName] + m.mu.RUnlock() + if proc == nil { + return false + } + backend := proc.Config.Backend + if proc.BackendOverride != "" { + backend = proc.BackendOverride + } + return m.credentialFileProves(agentName, proc.UID, backend) +} diff --git a/agent/authprobe_credential_gate_test.go b/agent/authprobe_credential_gate_test.go new file mode 100644 index 0000000..9d97d9c --- /dev/null +++ b/agent/authprobe_credential_gate_test.go @@ -0,0 +1,198 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/hivecommons/crux/config" +) + +// writeExpiredClaudeCreds writes a credentials.json whose OAuth token expired +// in the past — the shape the login detector MUST still pause on. +func writeExpiredClaudeCreds(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + body := map[string]any{ + "claudeAiOauth": map[string]any{ + "accessToken": "stale-token", + "expiresAt": time.Now().Add(-1 * time.Hour).UnixMilli(), + }, + } + data, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write: %v", err) + } +} + +// TestCredentialFileProves_ClaudeExpiryIsHonoured is the core of the #5291 gate: +// the detector may only skip a pause on PROOF of a working credential, and for +// claude that means an unexpired token — the one backend where expiry is +// actually knowable from the file. +func TestCredentialFileProves_ClaudeExpiryIsHonoured(t *testing.T) { + emptySharedPaths(t) + m := &Manager{} + home := t.TempDir() + t.Setenv("HOME", home) + cred := filepath.Join(home, ".claude", ".credentials.json") + + if m.credentialFileProves("supervisor", 0, "claude") { + t.Fatal("no credential file anywhere must not read as proof") + } + + writeExpiredClaudeCreds(t, cred) + if m.credentialFileProves("supervisor", 0, "claude") { + t.Fatal("an EXPIRED token must not read as proof — that agent genuinely needs a login") + } + + writeClaudeCreds(t, cred) + if !m.credentialFileProves("supervisor", 0, "claude") { + t.Fatal("a fresh token is exactly the proof the detector must stand down for") + } +} + +// TestCredentialFileProves_SharedPathCountsForTheAgent reproduces the incident's +// own shape: the operator's /login refreshed the SHARED credential, and the +// agent whose pane still showed login chrome must be recognised as +// authenticated through that shared file. +func TestCredentialFileProves_SharedPathCountsForTheAgent(t *testing.T) { + emptySharedPaths(t) + m := &Manager{} + t.Setenv("HOME", t.TempDir()) // the agent's own home is empty + + if m.credentialFileProves("supervisor", 0, "claude") { + t.Fatal("precondition: nothing should be proven yet") + } + writeClaudeCreds(t, sharedClaudeCredentialPath) + if !m.credentialFileProves("supervisor", 0, "claude") { + t.Fatal("the shared credential the operator just refreshed must count for the agent") + } +} + +// TestCredentialFileProves_UncheckableBackendsStayUnproven pins the deliberate +// limit: a backend with no credential file this process can read yields no +// proof, so the detector keeps its existing behaviour for it rather than +// silently never pausing. +func TestCredentialFileProves_UncheckableBackendsStayUnproven(t *testing.T) { + emptySharedPaths(t) + emptyCodexSharedPath(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEX_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + m := &Manager{} + + for _, backend := range append([]string{"gemini", "bob", "agy", ""}, config.InferenceBackends...) { + if m.credentialFileProves("supervisor", 0, backend) { + t.Errorf("backend %q has no checkable credential file — it must not claim proof", backend) + } + } +} + +// TestCredentialFileProves_CopilotAndCodexPresence documents the honest +// asymmetry: these two are checked for the PRESENCE of tokens, not for expiry, +// which is the same trade configHasTokens() already makes for the heal. +func TestCredentialFileProves_CopilotAndCodexPresence(t *testing.T) { + emptySharedPaths(t) + emptyCodexSharedPath(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEX_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + m := &Manager{} + + if m.credentialFileProves("supervisor", 0, "copilot") { + t.Fatal("precondition: no copilot tokens yet") + } + if err := os.MkdirAll(filepath.Dir(sharedCopilotConfigPath), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(sharedCopilotConfigPath, + []byte(`{"copilotTokens":{"github.com":"tok"}}`), 0o600); err != nil { + t.Fatalf("write copilot config: %v", err) + } + if !m.credentialFileProves("supervisor", 0, "copilot") { + t.Fatal("a copilot config carrying tokens must read as proof") + } + + if m.credentialFileProves("supervisor", 0, "codex") { + t.Fatal("precondition: no codex auth yet") + } + writeCodexAuth(t, codexSharedAuthFile, `{"tokens":{"access_token":"tok"}}`) + if !m.credentialFileProves("supervisor", 0, "codex") { + t.Fatal("a codex auth.json carrying tokens must read as proof") + } +} + +// TestAgentHasValidCredential_ResolvesBackendAndNilSafety covers the public +// entry point the detector calls: it must resolve the agent's own backend +// (including a runtime override) and must never panic on an unknown agent or a +// nil manager. +func TestAgentHasValidCredential_ResolvesBackendAndNilSafety(t *testing.T) { + emptySharedPaths(t) + home := t.TempDir() + t.Setenv("HOME", home) + writeClaudeCreds(t, filepath.Join(home, ".claude", ".credentials.json")) + + var nilMgr *Manager + if nilMgr.AgentHasValidCredential("supervisor") { + t.Fatal("a nil manager cannot prove anything") + } + + m := &Manager{agents: map[string]*AgentProcess{}} + if m.AgentHasValidCredential("supervisor") { + t.Fatal("an agent the manager does not know cannot be proven authenticated") + } + + m.agents["supervisor"] = &AgentProcess{Config: config.AgentConfig{Backend: "claude"}} + if !m.AgentHasValidCredential("supervisor") { + t.Fatal("a claude agent with a fresh token must be proven authenticated") + } + + // A runtime backend override must be what gets probed — otherwise an agent + // switched to gemini would keep answering from claude's credential. + m.agents["supervisor"].BackendOverride = "gemini" + if m.AgentHasValidCredential("supervisor") { + t.Fatal("the override backend must be probed, not the configured one") + } +} + +// TestAgentAuthState_UnchangedByTheCredentialExtraction is the refactor guard: +// AgentAuthState's answers must not have moved when its positive file probe was +// split out for the detector to share. +func TestAgentAuthState_UnchangedByTheCredentialExtraction(t *testing.T) { + emptySharedPaths(t) + emptyCodexSharedPath(t) + t.Setenv("CODEX_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + home := t.TempDir() + t.Setenv("HOME", home) + m := &Manager{} + + // claude, nothing on disk: UNKNOWN (absence is not proof of logged-out). + if avail, known := m.AgentAuthState("a", 0, "claude", false, false); avail || known { + t.Fatalf("claude with no file: got (%v,%v), want (false,false)", avail, known) + } + // copilot, nothing on disk: KNOWN needs-login. + if avail, known := m.AgentAuthState("a", 0, "copilot", false, false); avail || !known { + t.Fatalf("copilot with no file: got (%v,%v), want (false,true)", avail, known) + } + // codex, nothing on disk: KNOWN needs-login. + if avail, known := m.AgentAuthState("a", 0, "codex", false, false); avail || !known { + t.Fatalf("codex with no file: got (%v,%v), want (false,true)", avail, known) + } + // claude with a fresh token: authenticated and known. + writeClaudeCreds(t, filepath.Join(home, ".claude", ".credentials.json")) + if avail, known := m.AgentAuthState("a", 0, "claude", false, false); !avail || !known { + t.Fatalf("claude with a fresh token: got (%v,%v), want (true,true)", avail, known) + } + // The pane's own login signal still outranks the file (rule 3). + if avail, known := m.AgentAuthState("a", 0, "claude", false, true); avail || !known { + t.Fatalf("needsLogin must still win: got (%v,%v), want (false,true)", avail, known) + } +} diff --git a/agent/authprobe_test.go b/agent/authprobe_test.go new file mode 100644 index 0000000..52c100b --- /dev/null +++ b/agent/authprobe_test.go @@ -0,0 +1,649 @@ +package agent + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/hivecommons/crux/config" +) + +// writeClaudeCreds writes a credentials.json with a non-expired OAuth token at +// path, creating parent dirs. +func writeClaudeCreds(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + body := map[string]any{ + "claudeAiOauth": map[string]any{ + "accessToken": "test-token", + "expiresAt": time.Now().Add(24 * time.Hour).UnixMilli(), + }, + } + data, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write: %v", err) + } +} + +func writeCodexAuth(t *testing.T, path string, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir codex auth dir: %v", err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write codex auth: %v", err) + } +} + +// emptySharedPaths redirects the shared/legacy credential locations to a +// nonexistent temp dir — the exact condition observed on a per-agent-UID spoke, +// where /data/home/.claude/.credentials.json is absent and +// /data/home/.config/github-copilot is empty. +func emptySharedPaths(t *testing.T) { + t.Helper() + dir := t.TempDir() + origClaude, origCopilot := sharedClaudeCredentialPath, sharedCopilotConfigPath + origUserToken := copilotUserTokenProbePath + sharedClaudeCredentialPath = filepath.Join(dir, "absent-claude.json") + sharedCopilotConfigPath = filepath.Join(dir, "absent-copilot.json") + // The dashboard device-flow token at /data/copilot-user-token exists on a + // live hive host and short-circuits the copilot probe to "authenticated", + // so "no shared credentials" must redirect it too (#4585). + copilotUserTokenProbePath = filepath.Join(dir, "absent-copilot-user-token") + t.Cleanup(func() { + sharedClaudeCredentialPath = origClaude + sharedCopilotConfigPath = origCopilot + copilotUserTokenProbePath = origUserToken + }) +} + +func emptyCodexSharedPath(t *testing.T) { + t.Helper() + dir := t.TempDir() + orig := codexSharedAuthFile + codexSharedAuthFile = filepath.Join(dir, "absent-codex-auth.json") + t.Cleanup(func() { + codexSharedAuthFile = orig + }) +} + +func overrideCodexHomePrefix(t *testing.T, prefix string) { + t.Helper() + orig := codexHomePrefix + codexHomePrefix = prefix + t.Cleanup(func() { + codexHomePrefix = orig + }) +} + +// TestAgentAuthState_InferenceBackendNeverNeedsLogin is the primary guard: a +// method that has no interactive login (litellm/vllm/llm-d — the spoke's +// scanner runs METHOD=litellm) must NEVER report "needs login", no matter what +// the credential files say or what the process state is. +func TestAgentAuthState_InferenceBackendNeverNeedsLogin(t *testing.T) { + emptySharedPaths(t) + m := &Manager{} + + for _, backend := range config.InferenceBackends { + for _, running := range []bool{true, false} { + // Even a pane that looks like a login prompt cannot make an + // API-key backend "need login" — there is no login to perform. + for _, needsLogin := range []bool{true, false} { + avail, known := m.AgentAuthState("scanner", 2007, backend, running, needsLogin) + if !known || !avail { + t.Fatalf("backend=%s running=%v needsLogin=%v: got (avail=%v, known=%v), want authenticated+known", + backend, running, needsLogin, avail, known) + } + if known && !avail { + t.Fatalf("backend=%s: would render a login badge", backend) + } + } + } + } + + if BackendRequiresInteractiveAuth("litellm") { + t.Fatal("litellm must not be classified as an interactive-auth backend") + } +} + +// TestAgentAuthState_PerUIDCredentialsFound is the exact regression: the shared +// legacy path is empty but the agent's own per-UID home holds valid +// credentials. No badge may be raised. +func TestAgentAuthState_PerUIDCredentialsFound(t *testing.T) { + emptySharedPaths(t) + m := &Manager{} + + // Agents with a UID whose backend is an inference backend get a per-agent + // HOME; point that prefix at a temp dir and populate it. + home := t.TempDir() + origPrefix := claudeInferenceHomePrefixForTest(t, home+"/h-") + _ = origPrefix + writeClaudeCreds(t, filepath.Join(home+"/h-scanner", ".claude", ".credentials.json")) + + paths := agentClaudeCredentialPaths("scanner", 2007, "vllm") + if len(paths) < 2 { + t.Fatalf("want per-UID path then shared fallback, got %v", paths) + } + if paths[0] == sharedClaudeCredentialPath { + t.Fatalf("per-UID home must be probed FIRST, got %v", paths) + } + + // A non-running claude agent (so the probe actually runs) whose per-UID + // home holds credentials must report authenticated. + claudeHome := t.TempDir() + t.Setenv("HOME", claudeHome) + writeClaudeCreds(t, filepath.Join(claudeHome, ".claude", ".credentials.json")) + avail, known := m.AgentAuthState("scanner", 0, "claude", false, false) + if !known || !avail { + t.Fatalf("per-UID credentials present: got (avail=%v, known=%v), want authenticated", avail, known) + } +} + +// TestAgentAuthState_RunningAgentNoBadgeOnMissingFile encodes the precedence +// rule: positive evidence of health (running, not at a login prompt) beats +// absence-of-credential-file. This is the case the operator saw — scanner and +// quality running and passing deep health while the shared path was empty. +func TestAgentAuthState_RunningAgentNoBadgeOnMissingFile(t *testing.T) { + emptySharedPaths(t) + t.Setenv("HOME", t.TempDir()) // no credentials anywhere + m := &Manager{} + + avail, known := m.AgentAuthState("scanner", 2007, "claude", true /*running*/, false /*needsLogin*/) + if known && !avail { + t.Fatalf("running agent with empty shared path must not raise a login badge; got (avail=%v, known=%v)", avail, known) + } +} + +func TestAgentAuthState_ClaudeCredentialFileMissingThenPresent(t *testing.T) { + dir := t.TempDir() + home := filepath.Join(dir, "home") + shared := filepath.Join(dir, "shared", ".claude", ".credentials.json") + t.Setenv("HOME", home) + + origClaude, origCopilot := sharedClaudeCredentialPath, sharedCopilotConfigPath + sharedClaudeCredentialPath = shared + sharedCopilotConfigPath = filepath.Join(dir, "shared", ".copilot", "config.json") + t.Cleanup(func() { + sharedClaudeCredentialPath = origClaude + sharedCopilotConfigPath = origCopilot + }) + + m := &Manager{} + // A MISSING Claude credential file is INCONCLUSIVE, not proof of needs-login: + // Claude can be authenticated with no file on disk (in-memory session / + // keychain / a per-UID home this probe cannot read). Report unknown → no + // badge. (A real Claude login prompt is caught by the pane-scan needsLogin + // signal, covered by TestAgentAuthState_PaneLoginPromptWins.) This is the fix + // for the false 🔑 badge on authenticated-but-restarting Claude agents. + if avail, known := m.AgentAuthState("writer", 0, "claude", false, false); known || avail { + t.Fatalf("missing claude credentials: got (avail=%v, known=%v), want unknown (no badge)", avail, known) + } + + // A PRESENT+valid Claude credential file is still positive proof. + writeClaudeCreds(t, shared) + if avail, known := m.AgentAuthState("writer", 0, "claude", false, false); !known || !avail { + t.Fatalf("shared claude credentials present: got (avail=%v, known=%v), want authenticated", avail, known) + } +} + +func TestAgentAuthPathConstructionUsesIsolatedHomes(t *testing.T) { + emptySharedPaths(t) + home := t.TempDir() + t.Setenv("HOME", home) + prefix := filepath.Join(t.TempDir(), "agent-home-") + claudeInferenceHomePrefixForTest(t, prefix) + + if got := AgentHome("writer", 0, "claude"); got != home { + t.Fatalf("uid 0 AgentHome = %q, want HOME %q", got, home) + } + if got := AgentHome("writer", 2001, "claude"); got != interactiveHomePath("writer") { + t.Fatalf("claude agent home = %q, want per-agent home %q (#4596)", got, interactiveHomePath("writer")) + } + if got := AgentHome("writer", 2001, "litellm"); got != prefix+"writer" { + t.Fatalf("inference agent home = %q, want %q", got, prefix+"writer") + } + + claudePaths := agentClaudeCredentialPaths("writer", 0, "claude") + wantClaude := filepath.Join(home, ".claude", ".credentials.json") + if len(claudePaths) < 2 || claudePaths[0] != wantClaude || claudePaths[len(claudePaths)-1] != sharedClaudeCredentialPath { + t.Fatalf("claude credential paths = %v, want first %q and last shared %q", + claudePaths, wantClaude, sharedClaudeCredentialPath) + } + + copilotPaths := agentCopilotConfigPaths("writer", 0, "copilot") + wantCopilot := []string{ + filepath.Join(home, ".copilot", "config.json"), + filepath.Join(home, ".config", "github-copilot", "apps.json"), + filepath.Join(home, ".config", "github-copilot", "hosts.json"), + } + if len(copilotPaths) < len(wantCopilot)+1 { + t.Fatalf("copilot credential paths = %v, want home paths plus shared fallback", copilotPaths) + } + for i, want := range wantCopilot { + if copilotPaths[i] != want { + t.Fatalf("copilotPaths[%d] = %q, want %q (all paths %v)", i, copilotPaths[i], want, copilotPaths) + } + } + if copilotPaths[len(copilotPaths)-1] != sharedCopilotConfigPath { + t.Fatalf("copilot credential paths = %v, want shared fallback last %q", copilotPaths, sharedCopilotConfigPath) + } +} + +// TestAgentAuthState_TruePositivePreserved: an interactive backend whose missing +// credential file IS conclusive (copilot — no in-memory-session path like +// Claude's) and an agent that is NOT running must still report "needs login" — +// the file-probe signal has to keep working for those backends. (Claude is the +// exception: a missing file is inconclusive → no badge; see +// TestAgentAuthState_ClaudeCredentialFileMissingThenPresent.) +func TestAgentAuthState_TruePositivePreserved(t *testing.T) { + emptySharedPaths(t) + // uid>0 resolves HOME to /agents/scanner — on a live hive + // host that is a REAL agent home holding real copilot credentials, so the + // per-agent probe must be pointed at a temp tree too (#4585). + withSharedAgentHome(t) + t.Setenv("HOME", t.TempDir()) + m := &Manager{} + + avail, known := m.AgentAuthState("scanner", 2007, "copilot", false /*running*/, false) + if !known || avail { + t.Fatalf("no copilot credentials + not running must report needs-login; got (avail=%v, known=%v)", avail, known) + } +} + +// TestAgentAuthState_ClaudeMissingFileIsInconclusive: the specific fix — a Claude +// agent that is NOT running with NO credentials file must report UNKNOWN (no +// badge), because a missing Claude file does not prove needs-login (in-memory +// session / unreadable per-UID home). A false 🔑 badge on such an agent — the +// bug this fixes — was worse than showing nothing. +func TestAgentAuthState_ClaudeMissingFileIsInconclusive(t *testing.T) { + emptySharedPaths(t) + t.Setenv("HOME", t.TempDir()) + m := &Manager{} + + avail, known := m.AgentAuthState("quality", 2006, "claude", false /*running*/, false /*needsLogin*/) + if known || avail { + t.Fatalf("claude, not running, no file: want unknown (no badge); got (avail=%v, known=%v)", avail, known) + } + // But a real pane login prompt STILL badges, even for Claude. + if avail, known := m.AgentAuthState("quality", 2006, "claude", false, true /*needsLogin*/); !known || avail { + t.Fatalf("claude at a real login prompt: want needs-login; got (avail=%v, known=%v)", avail, known) + } +} + +// TestAgentAuthState_PaneLoginPromptWins: an agent genuinely parked at an +// interactive login prompt reports needs-login even while its process is +// running — rule 3 must not be masked by rule 2. +func TestAgentAuthState_PaneLoginPromptWins(t *testing.T) { + emptySharedPaths(t) + m := &Manager{} + + avail, known := m.AgentAuthState("scanner", 2007, "claude", true /*running*/, true /*needsLogin*/) + if !known || avail { + t.Fatalf("pane at login prompt must report needs-login; got (avail=%v, known=%v)", avail, known) + } +} + +func TestAgentAuthState_CopilotCredentials(t *testing.T) { + emptySharedPaths(t) + t.Setenv("HOME", t.TempDir()) + + m := &Manager{copilotAuthToken: "cached-token"} + if avail, known := m.AgentAuthState("planner", 0, "copilot", false, false); !known || !avail { + t.Fatalf("cached token: got (avail=%v, known=%v), want authenticated", avail, known) + } + + m.copilotAuthToken = "" + home := t.TempDir() + t.Setenv("HOME", home) + cfg := filepath.Join(home, ".copilot", "config.json") + if err := os.MkdirAll(filepath.Dir(cfg), 0o755); err != nil { + t.Fatalf("mkdir copilot config dir: %v", err) + } + if err := os.WriteFile(cfg, []byte("// generated\n{\"copilotTokens\":{\"user\":\"gho_test\"}}\n"), 0o600); err != nil { + t.Fatalf("write copilot config: %v", err) + } + if avail, known := m.AgentAuthState("planner", 0, "copilot", false, false); !known || !avail { + t.Fatalf("home config token: got (avail=%v, known=%v), want authenticated", avail, known) + } + + paths := agentCopilotConfigPaths("planner", 0, "copilot") + if len(paths) < 4 || paths[0] != cfg { + t.Fatalf("agentCopilotConfigPaths should prefer HOME config; got %v, want first %q", paths, cfg) + } + if !containsPath(paths, sharedCopilotConfigPath) { + t.Fatalf("agentCopilotConfigPaths should retain shared fallback; got %v", paths) + } + + if avail, known := m.AgentAuthState("planner", 0, "copilot", false, false); !known || !avail { + t.Fatalf("copilot config should still authenticate after path checks: got (avail=%v, known=%v)", avail, known) + } + m.copilotAuthToken = "" + t.Setenv("HOME", t.TempDir()) + emptySharedPaths(t) + if avail, known := m.AgentAuthState("planner", 0, "copilot", false, false); !known || avail { + t.Fatalf("copilot without tokens: got (avail=%v, known=%v), want known unauthenticated", avail, known) + } +} + +func TestAgentAuthState_CodexCredentials(t *testing.T) { + emptyCodexSharedPath(t) + t.Setenv("HOME", t.TempDir()) + // codexEnvHasCredentials reads the developer's real shell: an + // OPENAI_API_KEY / CODEX_API_KEY exported locally made the "no + // credentials" arm pass as authenticated. Pin both empty first. + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("CODEX_API_KEY", "") + m := &Manager{} + + if !BackendRequiresInteractiveAuth("codex") { + t.Fatal("codex must be classified as an interactive OAuth/device-login backend") + } + if avail, known := m.AgentAuthState("coder", 0, "codex", false, false); !known || avail { + t.Fatalf("codex without auth.json: got (avail=%v, known=%v), want known unauthenticated", avail, known) + } + + t.Setenv("CODEX_API_KEY", "env-api-key") + if avail, known := m.AgentAuthState("coder", 0, "codex", false, false); !known || !avail { + t.Fatalf("codex CODEX_API_KEY: got (avail=%v, known=%v), want authenticated", avail, known) + } + t.Setenv("CODEX_API_KEY", "") + + home := t.TempDir() + t.Setenv("HOME", home) + writeCodexAuth(t, filepath.Join(home, ".codex", "auth.json"), `{"tokens":{"access_token":"oauth-token"}}`) + if avail, known := m.AgentAuthState("coder", 0, "codex", false, false); !known || !avail { + t.Fatalf("codex OAuth auth.json: got (avail=%v, known=%v), want authenticated", avail, known) + } + + emptyCodexSharedPath(t) + overrideCodexHomePrefix(t, filepath.Join(t.TempDir(), ".codex-")) + writeCodexAuth(t, filepath.Join(codexHomePath("coder"), "auth.json"), `{"OPENAI_API_KEY":"api-key-login"}`) + if avail, known := m.AgentAuthState("coder", 2007, "codex", false, false); !known || !avail { + t.Fatalf("codex per-agent CODEX_HOME auth.json: got (avail=%v, known=%v), want authenticated", avail, known) + } + + paths := agentCodexAuthPaths("coder", 2007, "codex") + if len(paths) < 2 || paths[0] != filepath.Join(codexHomePath("coder"), "auth.json") { + t.Fatalf("agentCodexAuthPaths should prefer CODEX_HOME auth.json; got %v", paths) + } +} + +func TestAgentAuthState_BobAPIKeyGate(t *testing.T) { + m := &Manager{} + t.Setenv("HOME", "") + if home := AgentHome("shared", 0, "claude"); home != "/data/home" { + t.Fatalf("fallback HOME = %q, want /data/home", home) + } + if home := AgentHome("claude-agent", 2001, "claude"); home != interactiveHomePath("claude-agent") { + t.Fatalf("claude per-UID home = %q, want %q (#4596 per-agent layout)", home, interactiveHomePath("claude-agent")) + } + if avail, known := m.AgentAuthState("custom", 0, "custom-backend", false, false); avail || known { + t.Fatalf("custom non-interactive backend: got (avail=%v, known=%v), want unknown", avail, known) + } + if avail, known := m.AgentAuthState("gem", 0, "gemini", false, false); avail || known { + t.Fatalf("gemini probe: got (avail=%v, known=%v), want unknown", avail, known) + } + + if avail, known := m.AgentAuthState("writer", 0, bobBackend, false, true); !known || avail { + t.Fatalf("bob without key: got (avail=%v, known=%v), want known unauthenticated", avail, known) + } + + m.SetBobAPIKeyResolver(func() string { return "bob-key" }) + if avail, known := m.AgentAuthState("writer", 0, bobBackend, false, true); !known || !avail { + t.Fatalf("bob with key: got (avail=%v, known=%v), want authenticated", avail, known) + } +} + +func TestAgentAuthAvailableResolvesProcessState(t *testing.T) { + emptySharedPaths(t) + m := &Manager{ + agents: map[string]*AgentProcess{ + "runner": { + Name: "runner", + UID: 2001, + State: StateRunning, + Config: config.AgentConfig{ + Backend: "claude", + }, + }, + "override": { + Name: "override", + State: StateStopped, + Config: config.AgentConfig{Backend: "claude"}, + BackendOverride: "litellm", + }, + }, + } + + if avail, known := m.AgentAuthAvailable("missing"); avail || known { + t.Fatalf("missing agent: got (avail=%v, known=%v), want unknown", avail, known) + } + + if avail, known := m.AgentAuthAvailable("runner"); avail || known { + t.Fatalf("running claude agent: got (avail=%v, known=%v), want unknown/no badge", avail, known) + } + + m.agents["runner"].paneMu.Lock() + m.agents["runner"].NeedsLogin = true + m.agents["runner"].paneMu.Unlock() + if avail, known := m.AgentAuthAvailable("runner"); !known || avail { + t.Fatalf("login prompt: got (avail=%v, known=%v), want needs-login", avail, known) + } + + if avail, known := m.AgentAuthAvailable("override"); !known || !avail { + t.Fatalf("backend override to inference: got (avail=%v, known=%v), want authenticated", avail, known) + } +} + +func TestPermissionsWatcherRepairsBobStateDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), bobStateDirBase) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir bob state dir: %v", err) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat bob state dir: %v", err) + } + fixEntry(dir, info, logger) + + info, err = os.Stat(dir) + if err != nil { + t.Fatalf("stat repaired bob state dir: %v", err) + } + if got := info.Mode().Perm(); got&bobStateDirGroupRWX != bobStateDirGroupRWX { + t.Fatalf("bob state dir mode = %v, want group rwx bits", got) + } + + fixBobStateDirGroupWrite(dir, info.Mode(), logger) + again, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat idempotent bob state dir: %v", err) + } + if again.Mode().Perm() != info.Mode().Perm() { + t.Fatalf("idempotent repair changed mode from %v to %v", info.Mode().Perm(), again.Mode().Perm()) + } +} + +func TestPermissionsWatcherEnsuresAndWalksRedirectedDirs(t *testing.T) { + root := t.TempDir() + watched := filepath.Join(root, "watched") + goose := filepath.Join(root, "goose", "logs") + oldWatched, oldGoose := WatchedHomeDirs, GooseLogsDir + WatchedHomeDirs = []string{watched} + GooseLogsDir = goose + t.Cleanup(func() { + WatchedHomeDirs = oldWatched + GooseLogsDir = oldGoose + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + ensureWatchedDirs(logger) + for _, dir := range []string{watched, goose} { + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("expected %s to be created: %v", dir, err) + } + if !info.IsDir() { + t.Fatalf("%s is not a directory", dir) + } + } + + nestedBob := filepath.Join(watched, "agent", bobStateDirBase) + if err := os.MkdirAll(nestedBob, 0o755); err != nil { + t.Fatalf("mkdir nested bob dir: %v", err) + } + fixPermissions(logger) + info, err := os.Stat(nestedBob) + if err != nil { + t.Fatalf("stat nested bob dir: %v", err) + } + if info.Mode().Perm()&bobStateDirGroupRWX != bobStateDirGroupRWX { + t.Fatalf("nested bob dir mode = %v, want group rwx bits", info.Mode().Perm()) + } + + nonDir := filepath.Join(root, "not-dir") + if err := os.WriteFile(nonDir, []byte("x"), 0o600); err != nil { + t.Fatalf("write non-dir: %v", err) + } + WatchedHomeDirs = []string{nonDir} + fixPermissions(logger) +} + +type fakeFileInfo struct { + name string + mode os.FileMode + isDir bool + sys any +} + +func (f fakeFileInfo) Name() string { return f.name } +func (f fakeFileInfo) Size() int64 { return 0 } +func (f fakeFileInfo) Mode() os.FileMode { return f.mode } +func (f fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (f fakeFileInfo) IsDir() bool { return f.isDir } +func (f fakeFileInfo) Sys() any { return f.sys } + +func TestFixEntryWithSyntheticOwnership(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + dir := filepath.Join(t.TempDir(), "dir") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + fixEntry(dir, fakeFileInfo{ + name: "dir", + mode: 0o700 | os.ModeDir, + isDir: true, + sys: &syscall.Stat_t{Uid: uint32(DevUID), Gid: uint32(NodeGID)}, + }, logger) + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat dir: %v", err) + } + if got := info.Mode().Perm(); got&DirPerms != DirPerms { + t.Fatalf("dir mode = %v, want %v bits", got, DirPerms) + } + + file := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + fixEntry(file, fakeFileInfo{ + name: "file", + mode: 0o600, + sys: &syscall.Stat_t{Uid: uint32(DevUID), Gid: uint32(NodeGID)}, + }, logger) + info, err = os.Stat(file) + if err != nil { + t.Fatalf("stat file: %v", err) + } + if got := info.Mode().Perm(); got&FilePerms != FilePerms { + t.Fatalf("file mode = %v, want %v bits", got, FilePerms) + } + + fixEntry(file, fakeFileInfo{name: "no-stat", mode: 0o600, sys: struct{}{}}, logger) + fixEntry(file, fakeFileInfo{ + name: "other-owner", + mode: 0o600, + sys: &syscall.Stat_t{Uid: uint32(DevUID + 100), Gid: uint32(NodeGID)}, + }, logger) + fixEntry(file, fakeFileInfo{ + name: "root-owned", + mode: 0o600, + sys: &syscall.Stat_t{Uid: 0, Gid: 0}, + }, logger) +} + +func TestCoverageGuardsForRestartAndBobRelaunch(t *testing.T) { + m := &Manager{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + agents: map[string]*AgentProcess{}, + } + if err := m.RestartThenSendKick(context.Background(), "missing", "hello"); err == nil { + t.Fatal("RestartThenSendKick missing agent succeeded, want error") + } + if got := m.RelaunchBobAgentsAwaitingKey(context.Background()); got != nil { + t.Fatalf("RelaunchBobAgentsAwaitingKey without key = %v, want nil", got) + } + m.SetBobAPIKeyResolver(func() string { return "key" }) + if got := m.RelaunchBobAgentsAwaitingKey(context.Background()); len(got) != 0 { + t.Fatalf("RelaunchBobAgentsAwaitingKey with no candidates = %v, want empty", got) + } +} + +// TestLoginPromptPatterns_NoBareSlashLogin: a bare "/login" substring appears +// in ordinary agent output (an agent reviewing an auth route, a CLI printing +// its slash-command list) and must not trip the login detector. +func TestLoginPromptPatterns_NoBareSlashLogin(t *testing.T) { + benign := []string{ + "the handler for POST /login returns 302 on success", + " /help /login /model /status", + "added a test for /login rate limiting", + } + for _, line := range benign { + if paneShowsLoginPrompt([]string{line}) { + t.Fatalf("benign agent output falsely detected as a login prompt: %q", line) + } + } + + // True positives must still match: "/login" plus an imperative verb is a + // directive to the operator, which is what a real login screen renders. + truePositives := []string{ + "Please /login to continue", + "Please run /login to authenticate", + "Run /login", + "Type /login to sign in", + "You need to /login first", + "You must sign in to use Copilot", + "Use the url below to sign in", + "Enter one-time code", + "github.com/login/device", + } + for _, line := range truePositives { + if !paneShowsLoginPrompt([]string{line}) { + t.Fatalf("genuine login prompt not detected: %q", line) + } + } +} + +// claudeInferenceHomePrefixForTest redirects the per-agent inference home +// prefix and restores it on cleanup. +func claudeInferenceHomePrefixForTest(t *testing.T, prefix string) string { + t.Helper() + orig := inferenceHomePrefixOverride + inferenceHomePrefixOverride = prefix + t.Cleanup(func() { inferenceHomePrefixOverride = orig }) + return orig +} diff --git a/agent/backend_auth.go b/agent/backend_auth.go new file mode 100644 index 0000000..5423a77 --- /dev/null +++ b/agent/backend_auth.go @@ -0,0 +1,143 @@ +package agent + +import ( + "strings" + "time" + + "github.com/hivecommons/crux" +) + +// BackendAuth status values live in pkg/crux as part of the Crux +// runtime surface (hivecommons/hive#13536); these aliases keep pkg/agent's +// names valid. See crux.BackendAuthForbidden for why a bare 403 is its own +// state (#6500). +const ( + BackendAuthOK = crux.BackendAuthOK + BackendAuthUnlicensed = crux.BackendAuthUnlicensed + BackendAuthTokenExpired = crux.BackendAuthTokenExpired + BackendAuthUnreachable = crux.BackendAuthUnreachable + BackendAuthQuota = crux.BackendAuthQuota + BackendAuthForbidden = crux.BackendAuthForbidden +) + +// credentialRejectionSignals are the substrings in an auth-class pane line that +// DO identify a rejected credential — a 401, an explicit bad/invalid/expired +// credential, or a failed key verification — for which re-login is the correct +// remedy. Their ABSENCE from an auth-class line (e.g. a bare 403 Forbidden) +// means the cause is not verifiable from the pane, and the verdict must stay +// BackendAuthForbidden rather than asserting an expiry (#6500). +var credentialRejectionSignals = []string{ + "401", + "unauthorized", + "bad credentials", + "could not be validated", + "invalid api key", + "invalid or expired", + "api key verification failed", + "token expired", + "expired token", + "expired api key", +} + +// lineIndicatesCredentialRejection reports whether an auth-class line carries +// wording that identifies a rejected credential (as opposed to a bare +// authorization denial whose cause is undetermined). +func lineIndicatesCredentialRejection(lower string) bool { + for _, sig := range credentialRejectionSignals { + if strings.Contains(lower, sig) { + return true + } + } + return false +} + +// backendAuthErrorLimit caps how much of the offending pane line is retained +// in BackendAuthState.LastError, matching the limit classifyProviderError's +// callers already apply to LastError elsewhere. +const backendAuthErrorLimit = 200 + +// BackendAuthState is the per-agent backend-auth canary (#6558); see +// crux.BackendAuthState. +type BackendAuthState = crux.BackendAuthState + +// classifyBackendAuthStatus maps a classifyProviderError verdict onto the +// coarser BackendAuth states. ok=false means the class is transient (or +// unrecognised) and must NOT change the current BackendAuth state — a +// retryable 5xx or rate limit is not evidence the backend's auth is broken. +func classifyBackendAuthStatus(class, line string) (status string, ok bool) { + switch class { + case "auth": + // classifyProviderError's "auth" class covers the explicit "not + // licensed to use copilot" text (a revoked/expired seat — #6500), a + // bare 401/credential rejection, AND a bare 403 Forbidden whose cause + // the pane does not spell out. Only the first two justify a definitive + // verdict: + // - "not licensed": an entitlement failure, reported verbatim. + // - a recognised credential rejection (401, bad/invalid/expired + // credential): an expired/rejected token, for which re-login helps. + // Anything else in this class — most importantly a bare 403 — is an + // authorization denial whose cause (revoked seat vs. stale token vs. + // org policy) is NOT verifiable from the line. Reporting "token-expired" + // there would assert a credential expiry the code never checked and + // send the operator to a re-login that cannot fix it, which is the + // exact false claim in this issue. Report BackendAuthForbidden — an + // honest "auth denied, cause undetermined" — instead. + lower := strings.ToLower(line) + if strings.Contains(lower, "not licensed") { + return BackendAuthUnlicensed, true + } + if lineIndicatesCredentialRejection(lower) { + return BackendAuthTokenExpired, true + } + return BackendAuthForbidden, true + case "quota": + return BackendAuthQuota, true + case "api_error", "backend_auth_error": + // Covers classifyProviderError's generic api_error class, including + // the literal "inference backend unreachable" text a self-hosted LLM + // gateway renders (#6489's shape). + return BackendAuthUnreachable, true + default: + return "", false + } +} + +// markBackendAuthLocked records a backend-auth failure. Called with the same +// lock already held by the provider-error tracking beside it (Manager.mu via +// markProviderErrorLocked), so no separate lock guards BackendAuth. +// +// Since is preserved across repeated observations of the SAME status: it +// marks when the agent FIRST entered this failure state, which is what the +// hub's fleet-wide-down threshold measures. A status change (e.g. quota to +// unlicensed) resets the clock, because it is a different failure. +func (a *AgentProcess) markBackendAuthLocked(status, line string, now time.Time) { + if a.BackendAuth.Status == status { + a.BackendAuth.LastError = safeBackendAuthDetail(line) + return + } + a.BackendAuth = BackendAuthState{ + Status: status, + Since: now, + LastError: safeBackendAuthDetail(line), + } +} + +// clearBackendAuthLocked resets BackendAuth to ok. Called whenever the +// inference-kick watchdog finds no provider error on a pane it just checked +// (clearProviderErrorLocked's caller) — the spoke's only evidence of "the +// backend authenticated on the last check", which is what #6558 asked to +// treat as a successful turn. +func (a *AgentProcess) clearBackendAuthLocked(now time.Time) { + if a.BackendAuth.Status == "" || a.BackendAuth.Status == BackendAuthOK { + return + } + a.BackendAuth = BackendAuthState{Status: BackendAuthOK, Since: now} +} + +func safeBackendAuthDetail(s string) string { + s = strings.TrimSpace(s) + if len(s) > backendAuthErrorLimit { + return s[:backendAuthErrorLimit] + } + return s +} diff --git a/agent/backend_auth_test.go b/agent/backend_auth_test.go new file mode 100644 index 0000000..c508f4f --- /dev/null +++ b/agent/backend_auth_test.go @@ -0,0 +1,284 @@ +package agent + +import ( + "testing" + "time" + + "github.com/hivecommons/crux/config" +) + +func TestClassifyBackendAuthStatus(t *testing.T) { + cases := []struct { + name string + class string + line string + wantStatus string + wantOK bool + }{ + { + name: "explicit not-licensed text is unlicensed (#6500)", + class: "auth", + line: "✗ You are not licensed to use Copilot. (Request ID: abc)", + wantStatus: BackendAuthUnlicensed, + wantOK: true, + }, + { + name: "bare 401 auth class is token-expired", + class: "auth", + line: "API Error: 401 unauthorized", + wantStatus: BackendAuthTokenExpired, + wantOK: true, + }, + { + name: "bare 403 forbidden is undetermined, not a false expiry (#6500)", + class: "auth", + line: "API Error: 403 Forbidden", + wantStatus: BackendAuthForbidden, + wantOK: true, + }, + { + name: "403 whose body still says not licensed is unlicensed (#6500)", + class: "auth", + line: "unauthorized: not licensed to use Copilot", + wantStatus: BackendAuthUnlicensed, + wantOK: true, + }, + { + name: "bad credentials wording is token-expired", + class: "auth", + line: "Error: Bad credentials", + wantStatus: BackendAuthTokenExpired, + wantOK: true, + }, + { + name: "quota class is quota", + class: "quota", + line: "insufficient_quota", + wantStatus: BackendAuthQuota, + wantOK: true, + }, + { + name: "api_error class is unreachable", + class: "api_error", + line: "inference backend unreachable", + wantStatus: BackendAuthUnreachable, + wantOK: true, + }, + { + name: "rate_limit is transient, not a backend-auth verdict", + class: "rate_limit", + line: "API Error: 429 too many requests", + wantOK: false, + }, + { + name: "overloaded is transient, not a backend-auth verdict", + class: "overloaded", + line: "API Error: 529 overloaded", + wantOK: false, + }, + { + name: "retrying is transient, not a backend-auth verdict", + class: "retrying", + line: "Retrying in 5s · attempt 1/3", + wantOK: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + status, ok := classifyBackendAuthStatus(tc.class, tc.line) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if ok && status != tc.wantStatus { + t.Fatalf("status = %q, want %q", status, tc.wantStatus) + } + }) + } +} + +func newBackendAuthTestAgent(t *testing.T) *AgentProcess { + t.Helper() + m := NewManager(map[string]config.AgentConfig{ + "vinf": {Backend: "vllm"}, + }, discardLogger(), ProjectContext{}) + m.mu.RLock() + agent := m.agents["vinf"] + m.mu.RUnlock() + return agent +} + +// TestMarkProviderErrorLockedBare403IsUndetermined drives the REAL path from a +// pane a Copilot enterprise 403 renders — a bare "API Error: 403 Forbidden" +// with no "not licensed" wording — through classifyProviderError and +// markProviderErrorLocked, and asserts the canary reports the honest +// BackendAuthForbidden ("cause undetermined") rather than the false +// BackendAuthTokenExpired it used to assert (#6500). A token expiry the code +// never verified must not be presented to an operator as one. +func TestMarkProviderErrorLockedBare403IsUndetermined(t *testing.T) { + t.Setenv(ProviderErrorBackoffBaseEnv, "10s") + t.Setenv(ProviderErrorBackoffMaxEnv, "40s") + m := NewManager(map[string]config.AgentConfig{ + "vinf": {Backend: "vllm"}, + }, discardLogger(), ProjectContext{}) + m.mu.RLock() + agent := m.agents["vinf"] + m.mu.RUnlock() + + match, ok := classifyProviderError("API Error: 403 Forbidden\n") + if !ok || match.Class != "auth" { + t.Fatalf("classifyProviderError(403) = %+v, ok=%v; want auth class", match, ok) + } + m.markProviderErrorLocked(agent, match, time.Now()) + if agent.BackendAuth.Status != BackendAuthForbidden { + t.Fatalf("BackendAuth.Status = %q, want %q", agent.BackendAuth.Status, BackendAuthForbidden) + } +} + +func TestMarkBackendAuthLocked_FirstObservationSetsSince(t *testing.T) { + agent := newBackendAuthTestAgent(t) + now := time.Now() + + agent.markBackendAuthLocked(BackendAuthUnlicensed, "not licensed", now) + if agent.BackendAuth.Status != BackendAuthUnlicensed { + t.Fatalf("Status = %q, want %q", agent.BackendAuth.Status, BackendAuthUnlicensed) + } + if !agent.BackendAuth.Since.Equal(now) { + t.Fatalf("Since = %v, want %v", agent.BackendAuth.Since, now) + } + if agent.BackendAuth.LastError != "not licensed" { + t.Fatalf("LastError = %q, want %q", agent.BackendAuth.LastError, "not licensed") + } +} + +func TestMarkBackendAuthLocked_SameStatusPreservesSince(t *testing.T) { + agent := newBackendAuthTestAgent(t) + first := time.Now() + agent.markBackendAuthLocked(BackendAuthUnlicensed, "not licensed v1", first) + + later := first.Add(10 * time.Minute) + agent.markBackendAuthLocked(BackendAuthUnlicensed, "not licensed v2", later) + + if !agent.BackendAuth.Since.Equal(first) { + t.Fatalf("Since moved on repeat observation: got %v, want original %v", agent.BackendAuth.Since, first) + } + if agent.BackendAuth.LastError != "not licensed v2" { + t.Fatalf("LastError not updated on repeat observation: %q", agent.BackendAuth.LastError) + } +} + +func TestMarkBackendAuthLocked_StatusChangeResetsSince(t *testing.T) { + agent := newBackendAuthTestAgent(t) + first := time.Now() + agent.markBackendAuthLocked(BackendAuthQuota, "insufficient_quota", first) + + later := first.Add(time.Hour) + agent.markBackendAuthLocked(BackendAuthUnlicensed, "not licensed", later) + + if agent.BackendAuth.Status != BackendAuthUnlicensed { + t.Fatalf("Status = %q, want %q", agent.BackendAuth.Status, BackendAuthUnlicensed) + } + if !agent.BackendAuth.Since.Equal(later) { + t.Fatalf("Since not reset on status change: got %v, want %v", agent.BackendAuth.Since, later) + } +} + +func TestClearBackendAuthLocked(t *testing.T) { + agent := newBackendAuthTestAgent(t) + + // No-op on an already-ok agent: no spurious Since stamp. + clearAt := time.Now() + agent.clearBackendAuthLocked(clearAt) + if agent.BackendAuth.Status != "" { + t.Fatalf("clearing an ok agent set Status: %q", agent.BackendAuth.Status) + } + + agent.markBackendAuthLocked(BackendAuthTokenExpired, "API Error: 401", time.Now()) + agent.clearBackendAuthLocked(clearAt) + if agent.BackendAuth.Status != BackendAuthOK { + t.Fatalf("Status after clear = %q, want %q", agent.BackendAuth.Status, BackendAuthOK) + } + if !agent.BackendAuth.Since.Equal(clearAt) { + t.Fatalf("Since after clear = %v, want %v", agent.BackendAuth.Since, clearAt) + } + if agent.BackendAuth.LastError != "" { + t.Fatalf("LastError survived clear: %q", agent.BackendAuth.LastError) + } +} + +func TestSafeBackendAuthDetail_TruncatesLongLines(t *testing.T) { + long := make([]byte, backendAuthErrorLimit+50) + for i := range long { + long[i] = 'x' + } + got := safeBackendAuthDetail(string(long)) + if len(got) != backendAuthErrorLimit { + t.Fatalf("len(got) = %d, want %d", len(got), backendAuthErrorLimit) + } +} + +// TestMarkProviderErrorLockedUpdatesBackendAuth verifies the end-to-end wiring +// this issue asked for: classifyProviderError's verdict, once observed by +// markProviderErrorLocked, becomes a BackendAuth transition — including on +// the backoff-still-active early return, where an operator still needs "still +// unlicensed" to read correctly. +func TestMarkProviderErrorLockedUpdatesBackendAuth(t *testing.T) { + t.Setenv(ProviderErrorBackoffBaseEnv, "10s") + t.Setenv(ProviderErrorBackoffMaxEnv, "40s") + m := NewManager(map[string]config.AgentConfig{ + "vinf": {Backend: "vllm"}, + }, discardLogger(), ProjectContext{}) + m.mu.RLock() + agent := m.agents["vinf"] + m.mu.RUnlock() + + now := time.Now() + match := providerErrorMatch{Class: "auth", Line: "✗ You are not licensed to use Copilot."} + m.markProviderErrorLocked(agent, match, now) + if agent.BackendAuth.Status != BackendAuthUnlicensed { + t.Fatalf("BackendAuth.Status = %q, want %q", agent.BackendAuth.Status, BackendAuthUnlicensed) + } + firstSince := agent.BackendAuth.Since + + // Repeat within the active backoff window (early return in + // markProviderErrorLocked) must still refresh BackendAuth. + later := now.Add(2 * time.Second) + m.markProviderErrorLocked(agent, match, later) + if agent.BackendAuth.Status != BackendAuthUnlicensed { + t.Fatalf("BackendAuth.Status after repeat = %q, want %q", agent.BackendAuth.Status, BackendAuthUnlicensed) + } + if !agent.BackendAuth.Since.Equal(firstSince) { + t.Fatalf("Since moved on repeat: got %v, want %v", agent.BackendAuth.Since, firstSince) + } + + // A clear resets it to ok. + m.clearProviderErrorLocked(agent, later.Add(time.Second)) + if agent.BackendAuth.Status != BackendAuthOK { + t.Fatalf("BackendAuth.Status after clear = %q, want %q", agent.BackendAuth.Status, BackendAuthOK) + } +} + +// TestMarkProviderErrorLockedTransientDoesNotSetBackendAuth verifies that a +// retryable class (rate_limit/overloaded/retrying) never touches BackendAuth, +// so a transient blip cannot false-positive the fleet-wide auth canary. +func TestMarkProviderErrorLockedTransientDoesNotSetBackendAuth(t *testing.T) { + t.Setenv(ProviderErrorBackoffBaseEnv, "10s") + t.Setenv(ProviderErrorBackoffMaxEnv, "40s") + m := NewManager(map[string]config.AgentConfig{ + "vinf": {Backend: "vllm"}, + }, discardLogger(), ProjectContext{}) + m.mu.RLock() + agent := m.agents["vinf"] + m.mu.RUnlock() + + m.markProviderErrorLocked(agent, providerErrorMatch{Class: "rate_limit", Line: "API Error: 429"}, time.Now()) + if agent.BackendAuth.Status != "" { + t.Fatalf("transient rate_limit set BackendAuth.Status = %q, want empty", agent.BackendAuth.Status) + } +} + +func TestRollUpProviderStatusAliasDelegates(t *testing.T) { + got := RollUpProviderStatus([]ProviderAgentEvidence{{Agent: "a", Provider: "copilot", Enabled: true, AuthStatus: BackendAuthUnlicensed}}) + if len(got) != 1 || got[0].AuthState != ProviderAuthNeedsLogin { + t.Fatalf("RollUpProviderStatus = %+v, want one needs-login provider", got) + } +} diff --git a/agent/backend_coverage_test.go b/agent/backend_coverage_test.go new file mode 100644 index 0000000..e1329a0 --- /dev/null +++ b/agent/backend_coverage_test.go @@ -0,0 +1,336 @@ +package agent + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/hivecommons/crux/config" +) + +// TestAgentCopilotConfigPaths_PerUIDFirst locks down the ORDERING contract of +// agentCopilotConfigPaths: the per-UID files must be probed before the shared +// path, because the per-UID file is the one the agent's own CLI writes. If the +// shared path came first, a stale shared credential would mask a fresh per-agent +// login and the dashboard would report the wrong auth state. +func TestAgentCopilotConfigPaths_PerUIDFirst(t *testing.T) { + emptySharedPaths(t) + + // A gateway backend routes AgentHome to the per-agent inference home, which + // is the case where per-UID paths actually differ from the shared one. + paths := agentCopilotConfigPaths("ci-maintainer", 2007, "watsonx") + if len(paths) < 2 { + t.Fatalf("want per-UID paths plus the shared path, got %v", paths) + } + home := AgentHome("ci-maintainer", 2007, "watsonx") + if home == "" { + t.Fatal("AgentHome returned empty for a per-UID gateway agent") + } + want := []string{ + filepath.Join(home, ".copilot", "config.json"), + filepath.Join(home, ".config", "github-copilot", "apps.json"), + filepath.Join(home, ".config", "github-copilot", "hosts.json"), + } + for i, w := range want { + if paths[i] != w { + t.Errorf("paths[%d] = %q, want %q", i, paths[i], w) + } + } + // The shared path must be present, and must come LAST. + if got := paths[len(paths)-1]; got != sharedCopilotConfigPath { + t.Errorf("last path = %q, want the shared path %q", got, sharedCopilotConfigPath) + } +} + +// TestAgentCopilotConfigPaths_NoDuplicateSharedPath: when AgentHome resolves to +// the same place the shared credential lives, the shared path must not be +// appended twice. containsPath exists precisely to prevent that. +func TestAgentCopilotConfigPaths_NoDuplicateSharedPath(t *testing.T) { + emptySharedPaths(t) + for _, backend := range []string{"claude", "copilot", "watsonx", ""} { + paths := agentCopilotConfigPaths("ci-maintainer", 2007, backend) + seen := map[string]int{} + for _, p := range paths { + seen[p]++ + } + for p, n := range seen { + if n > 1 { + t.Errorf("backend %q: path %q appears %d times, want once", backend, p, n) + } + } + } +} + +// TestAgentAuthAvailable_UnknownAgent: the dashboard registers +// AgentAuthAvailable as a per-agent provider and calls it by name alone. An +// agent the manager has never heard of must return known=false ("no opinion") +// so the UI renders NO badge, rather than a false "not authenticated" badge. +func TestAgentAuthAvailable_UnknownAgent(t *testing.T) { + m := &Manager{agents: map[string]*AgentProcess{}} + avail, known := m.AgentAuthAvailable("no-such-agent") + if known { + t.Errorf("known = true for an unknown agent, want false (no badge)") + } + if avail { + t.Errorf("available = true for an unknown agent, want false") + } +} + +// TestAgentAuthAvailable_UsesBackendOverride is the substantive behaviour of +// AgentAuthAvailable: it resolves the agent's EFFECTIVE backend itself. An agent +// configured for an interactive CLI but overridden onto watsonx authenticates by +// IAM, so it must read as authenticated — if the override were ignored, a +// switched agent would show a spurious login badge forever. +func TestAgentAuthAvailable_UsesBackendOverride(t *testing.T) { + emptySharedPaths(t) + proc := &AgentProcess{ + Name: "ci-maintainer", + UID: 2007, + State: StateRunning, + BackendOverride: "watsonx", + } + proc.Config.Backend = "claude" + proc.NeedsLogin = true // pane looks like a login prompt; must be ignored + + m := &Manager{agents: map[string]*AgentProcess{"ci-maintainer": proc}} + + avail, known := m.AgentAuthAvailable("ci-maintainer") + if !known || !avail { + t.Fatalf("watsonx-overridden agent: got (avail=%v, known=%v), want authenticated+known", avail, known) + } + + // Sanity: the same result must come from the lower-level call with the + // override applied, proving AgentAuthAvailable resolved the override and + // not the configured backend. + wantAvail, wantKnown := m.AgentAuthState("ci-maintainer", proc.UID, "watsonx", true, true) + if avail != wantAvail || known != wantKnown { + t.Errorf("AgentAuthAvailable = (%v,%v), AgentAuthState(watsonx) = (%v,%v); override was not applied", + avail, known, wantAvail, wantKnown) + } +} + +// TestCheckBlockedThrash_TripsOnlyAtThreshold exercises the breaker end to end +// through checkBlockedThrash (the marker-matching + state-bookkeeping wrapper), +// not just the pure sliding-window helper. Below the threshold the agent must +// NOT be paused; the breaker only fires once the loop is established. +// TestCheckBlockedThrash_MatchesEveryMarker: every marker in +// blockedActionMarkers must actually be recognised. A marker that silently +// stopped matching would disable the breaker for that whole class of block. +// TestRecordBlockedAndCheck_WindowAndCooldown pins the pure decision function: +// stale entries fall out of the sliding window, and after a trip the cooldown +// suppresses a second trip so one bad loop does not pause an agent repeatedly. +// TestBackendBinary_GatewayBackendsAllLaunchClaude pins the derivation added by +// this PR: every model-gateway backend launches the SAME claude CLI, because the +// backend name selects the upstream route (via ANTHROPIC_BASE_URL), not the +// binary. This is what makes adding a backend to config.InferenceBackends +// sufficient — no second edit in the launcher. +func TestBackendBinary_GatewayBackendsAllLaunchClaude(t *testing.T) { + // backendBinary may return a resolved absolute path (CI installs stub + // binaries on PATH), so compare the basename — the identity of the CLI is + // what matters here, not where it was found. + for _, b := range config.InferenceBackends { + got, err := backendBinary(b) + if err != nil { + t.Errorf("backendBinary(%q) err = %v, want nil", b, err) + continue + } + if filepath.Base(got) != "claude" { + t.Errorf("backendBinary(%q) = %q, want the claude CLI (gateways share one binary)", b, got) + } + } + // The agentic CLIs installed in the unit-test image must still map to their own binaries. + // Pi is covered by backendBinaryName and Dockerfile tests because the host + // unit-test environment does not install the pi CLI. + for backend, want := range map[string]string{ + "claude": "claude", "copilot": "copilot", "gemini": "gemini", + "goose": "goose", "bob": "bob", + } { + got, err := backendBinary(backend) + if err != nil || filepath.Base(got) != want { + t.Errorf("backendBinary(%q) = (%q, %v), want basename %q with nil error", backend, got, err, want) + } + } +} + +// TestSetBackendOverride_RejectsUndispatchableBackend covers the door this PR +// closed: /switch/{backend} used to accept ANY string and set it, so the agent +// was restarted into a backend the launcher could not start and died with +// "unknown backend" — with no signal at the moment of the change. The override +// must be rejected AND left unmodified. +func TestSetBackendOverride_RejectsUndispatchableBackend(t *testing.T) { + proc := &AgentProcess{Name: "ci-maintainer", BackendOverride: "claude"} + m := &Manager{ + agents: map[string]*AgentProcess{"ci-maintainer": proc}, + logger: discardLogger(), + } + + err := m.SetBackendOverride("ci-maintainer", "totally-made-up") + if err == nil { + t.Fatal("SetBackendOverride accepted a backend the launcher cannot dispatch") + } + if !strings.Contains(err.Error(), "totally-made-up") { + t.Errorf("error %q should name the rejected backend", err.Error()) + } + if proc.BackendOverride != "claude" { + t.Errorf("a rejected override mutated state: BackendOverride = %q, want %q unchanged", + proc.BackendOverride, "claude") + } + + // watsonx — the backend this PR registered — must be ACCEPTED and applied. + if err := m.SetBackendOverride("ci-maintainer", "watsonx"); err != nil { + t.Fatalf("SetBackendOverride(watsonx) = %v, want nil", err) + } + if proc.BackendOverride != "watsonx" { + t.Errorf("BackendOverride = %q, want \"watsonx\"", proc.BackendOverride) + } + + // A missing agent must still be reported as not found, not as a bad backend. + if err := m.SetBackendOverride("no-such-agent", "claude"); err == nil || + !strings.Contains(err.Error(), "not found") { + t.Errorf("SetBackendOverride on a missing agent = %v, want a 'not found' error", err) + } +} + +// TestBackendBinaryName_EverySupportedBackendResolves is the guard for the +// accept-then-fail class of bug. config.ValidateBackend accepts every member of +// config.SupportedBackends() at config-set time, so the launcher MUST be able to +// dispatch every one of them — otherwise a backend is persisted happily and then +// dies hours later at kick time with "unknown backend". +// +// This regression was real: config.CLIBackends listed codex and aider, but +// backendBinary's hand-written map did not, so `backend: codex` was accepted and +// then failed at launch. The old test enumerated only the backends that were +// already in the map, so it could never catch a missing one. This test derives +// its cases from the canonical list instead, which is what makes it able to. +// +// backendBinaryName is used rather than backendBinary so this asserts the +// mapping is COMPLETE without also requiring every CLI to be installed here. +func TestBackendBinaryName_EverySupportedBackendResolves(t *testing.T) { + supported := config.SupportedBackends() + if len(supported) == 0 { + t.Fatal("config.SupportedBackends() is empty — this test would vacuously pass") + } + for _, b := range supported { + binary, err := backendBinaryName(b) + if err != nil { + t.Errorf("config accepts backend %q but the launcher cannot dispatch it: %v", b, err) + continue + } + if binary == "" { + t.Errorf("backendBinaryName(%q) returned an empty binary name", b) + } + } +} + +func TestManagerBackendBinaryName_ConfiguredGatewayResolvesToClaude(t *testing.T) { + m := NewManager(nil, discardLogger(), ProjectContext{}) + m.SetGatewayBackendChecker(func(backend string) bool { + return strings.EqualFold(backend, "openrouter") + }) + for _, backend := range []string{"openrouter", "OpenRouter"} { + binary, err := m.backendBinaryName(backend) + if err != nil { + t.Errorf("configured gateway backend %q must launch, got %v", backend, err) + continue + } + if binary != "claude" { + t.Errorf("m.backendBinaryName(%q) = %q, want claude", backend, binary) + } + } +} + +func TestAcceptedBackendsAreLaunchableByConstruction(t *testing.T) { + g := config.AgentRuntimeGovernorConfig{ + Gateways: []config.GatewayConfig{ + {Name: "openrouter", Kind: config.GatewayKindOpenRouter, Endpoint: "https://openrouter.ai/api/v1"}, + {Name: "Corp-Watsonx", Kind: config.GatewayKindWatsonx, Endpoint: "https://us-south.ml.cloud.ibm.com/ml/gateway"}, + }, + } + m := NewManager(nil, discardLogger(), ProjectContext{}) + m.SetGatewayBackendChecker(func(backend string) bool { + return g.ResolveGateway(backend) != nil && backend != "" + }) + + backends := append([]string{""}, config.SupportedBackends()...) + backends = append(backends, "openrouter", "OPENROUTER", "corp-watsonx") + for _, backend := range backends { + if err := g.ValidateBackend(backend); err != nil { + t.Errorf("test backend %q should be accepted by config: %v", backend, err) + continue + } + if err := m.validateBackendName(backend); err != nil { + t.Errorf("backend %q accepted by config but rejected by manager validation: %v", backend, err) + continue + } + if backend == "" { + continue + } + if binary, err := m.backendBinaryName(backend); err != nil || binary == "" { + t.Errorf("backend %q validates but is not launchable: binary=%q err=%v", backend, binary, err) + } + } +} + +// TestBackendBinaryName_AgyLaunchesAntigravityCLI pins agy to its own binary. +// +// agy is the Antigravity CLI, Google's replacement for the Gemini CLI: Google +// stopped serving Gemini CLI for personal and AI Pro accounts on 2026-06-18, so +// for any hive whose Google access is a subscription rather than metered API +// credits, agy is the ONLY reachable Google backend. It is emphatically not an +// alias for gemini — different binary, different auth, different flags — so a +// future tidy-up that folds it into the gemini derivation would silently break +// every subscription-backed Google agent. This catches that. +func TestBackendBinaryName_AgyLaunchesAntigravityCLI(t *testing.T) { + if !config.IsCLIBackend("agy") { + t.Fatal("precondition failed: \"agy\" is not a config.CLIBackends member") + } + got, err := backendBinaryName("agy") + if err != nil { + t.Fatalf("backendBinaryName(\"agy\") err = %v, want nil", err) + } + if got != "agy" { + t.Errorf("backendBinaryName(\"agy\") = %q, want \"agy\"", got) + } +} + +// TestBackendBinaryName_CodexAndAiderLaunchTheirOwnBinaries pins the specific +// regression: codex and aider are agentic CLIs of their own, not aliases and not +// gateway backends, so each must exec a binary of its own name. If either were +// ever folded into the claude derivation this would catch it. +func TestBackendBinaryName_CodexAndAiderLaunchTheirOwnBinaries(t *testing.T) { + for _, backend := range []string{"codex", "aider"} { + if !config.IsCLIBackend(backend) { + t.Fatalf("precondition failed: %q is no longer a config.CLIBackends member", backend) + } + got, err := backendBinaryName(backend) + if err != nil { + t.Errorf("backendBinaryName(%q) err = %v, want nil", backend, err) + continue + } + if got != backend { + t.Errorf("backendBinaryName(%q) = %q, want %q", backend, got, backend) + } + } +} + +// TestBackendBinaryName_PiLaunchesPiBinary pins the regression that made +// backend: pi launch goose. Pi is a first-class CLI backend, so the launcher +// must exec the pi binary directly rather than reaching an alias. +func TestBackendBinaryName_PiLaunchesPiBinary(t *testing.T) { + got, err := backendBinaryName("pi") + if err != nil { + t.Fatalf("backendBinaryName(\"pi\") err = %v, want nil", err) + } + if got != "pi" { + t.Errorf("backendBinaryName(\"pi\") = %q, want \"pi\"", got) + } +} + +// TestBackendBinaryName_RejectsUnknown keeps the negative path honest: deriving +// from the canonical lists must not turn backendBinaryName into a function that +// accepts anything. +func TestBackendBinaryName_RejectsUnknown(t *testing.T) { + if _, err := backendBinaryName("totally-made-up"); err == nil { + t.Fatal("backendBinaryName accepted a backend that is in neither canonical list") + } +} diff --git a/agent/backend_launch_cmd_test.go b/agent/backend_launch_cmd_test.go new file mode 100644 index 0000000..ced3264 --- /dev/null +++ b/agent/backend_launch_cmd_test.go @@ -0,0 +1,174 @@ +package agent + +import ( + "strings" + "testing" +) + +// backendLaunchCmd is the single source of truth for how each backend's CLI is +// launched inside a tmux pane. The agy and codex branches already have +// dedicated contract tests (agy_launch_test.go, codex_launch_test.go); these +// tests cover the remaining branches, with particular weight on the claude and +// copilot branches because they carry the GitHub-MCP write-deny security +// contract: agents must author via the App-gated gh wrapper, never as the +// login user via the MCP. + +func TestBackendLaunchCmd_ClaudeCarriesWriteAndHostStateDenies(t *testing.T) { + got := backendLaunchCmd("claude", "claude-fable-5", "claude", false, "") + + if !strings.HasPrefix(got, "claude --model claude-fable-5 --dangerously-skip-permissions") { + t.Fatalf("claude launch cmd missing base contract: %q", got) + } + if strings.Contains(got, "--bare") { + t.Errorf("non-inference claude launch must not carry --bare: %q", got) + } + if !strings.Contains(got, claudeGitHubWriteDenyFlags) { + t.Errorf("claude launch cmd missing GitHub MCP write-deny flags: %q", got) + } + if want := claudeHostStateDenyFlags(); want != "" && !strings.Contains(got, want) { + t.Errorf("claude launch cmd missing host-state deny flags %q: %q", want, got) + } +} + +func TestBackendLaunchCmd_ClaudeInferenceAddsBareSettings(t *testing.T) { + got := backendLaunchCmd("claude", "m", "claude", true, "") + + if !strings.Contains(got, " --bare --settings "+claudeInferenceSettingsPath) { + t.Fatalf("inference claude launch must carry --bare --settings %s: %q", + claudeInferenceSettingsPath, got) + } + // The write-deny contract applies in every mode, inference included. + if !strings.Contains(got, claudeGitHubWriteDenyFlags) { + t.Errorf("inference claude launch cmd missing GitHub MCP write-deny flags: %q", got) + } +} + +func TestBackendLaunchCmd_CopilotDeniesMCPWritesAndPassesModelThrough(t *testing.T) { + got := backendLaunchCmd("copilot", "auto", "copilot", false, "") + + if !strings.HasPrefix(got, "copilot --model auto --no-auto-update --allow-all") { + t.Fatalf("copilot launch cmd missing base contract: %q", got) + } + if !strings.Contains(got, copilotGitHubWriteDenyFlags) { + t.Errorf("copilot launch cmd missing GitHub MCP write-deny flags: %q", got) + } + // PRIMARY defense: the flag that would register the MCP write tools must + // never appear. + if strings.Contains(got, "--enable-all-github-mcp-tools") { + t.Errorf("copilot launch cmd must never enable all GitHub MCP tools: %q", got) + } +} + +func TestBackendLaunchCmd_EveryMCPWriteToolDeniedForClaudeAndCopilot(t *testing.T) { + // The two deny lists must stay the same logical set. Guard the set here so + // a tool added to one spelling but not the other fails loudly. + writeTools := []string{ + "create_pull_request", + "create_pull_request_with_copilot", + "merge_pull_request", + "create_issue", + "update_issue", + "add_issue_comment", + } + claudeCmd := backendLaunchCmd("claude", "m", "claude", false, "") + copilotCmd := backendLaunchCmd("copilot", "m", "copilot", false, "") + for _, tool := range writeTools { + if want := "--disallowed-tools 'mcp__github__" + tool + "'"; !strings.Contains(claudeCmd, want) { + t.Errorf("claude launch cmd missing deny for %s: %q", tool, claudeCmd) + } + if want := "--deny-tool='github-mcp-server(" + tool + ")'"; !strings.Contains(copilotCmd, want) { + t.Errorf("copilot launch cmd missing deny for %s: %q", tool, copilotCmd) + } + } +} + +func TestBackendLaunchCmd_SimpleModelFlagBackends(t *testing.T) { + for _, backend := range []string{"gemini", "pi"} { + got := backendLaunchCmd("bin", "some-model", backend, false, "") + if want := "bin --model some-model"; got != want { + t.Errorf("backendLaunchCmd(%s) = %q, want %q", backend, got, want) + } + } +} + +func TestBackendLaunchCmd_GooseRunWithAndWithoutModel(t *testing.T) { + if got, want := backendLaunchCmd("goose", "", "goose", false, ""), "goose run -s"; got != want { + t.Errorf("goose without model = %q, want %q", got, want) + } + if got, want := backendLaunchCmd("goose", "gpt-6-astra", "goose", false, ""), + "goose run -s --model gpt-6-astra"; got != want { + t.Errorf("goose with model = %q, want %q", got, want) + } +} + +func TestBackendLaunchCmd_UnknownBackendFallsBackToBareBinary(t *testing.T) { + if got, want := backendLaunchCmd("somebin", "ignored-model", "mystery", false, "high"), "somebin"; got != want { + t.Errorf("unknown backend = %q, want bare binary %q", got, want) + } +} + +func TestBackendLaunchCmd_BobDelegatesToBobLaunchCmd(t *testing.T) { + if got, want := backendLaunchCmd("bobbin", "m", bobBackend, false, ""), bobLaunchCmd("bobbin"); got != want { + t.Errorf("bob backend = %q, want bobLaunchCmd contract %q", got, want) + } +} + +func TestCodexEffortFlag(t *testing.T) { + if got := codexEffortFlag(""); got != "" { + t.Errorf("codexEffortFlag(\"\") = %q, want empty", got) + } + if got, want := codexEffortFlag("xhigh"), ` -c model_reasoning_effort="xhigh"`; got != want { + t.Errorf("codexEffortFlag(xhigh) = %q, want %q", got, want) + } +} + +func TestSanitizeRestartReason(t *testing.T) { + if got, want := sanitizeRestartReason(" "), "operator"; got != want { + t.Errorf("blank reason = %q, want %q", got, want) + } + if got, want := sanitizeRestartReason(" tmux pane died "), "tmux pane died"; got != want { + t.Errorf("trimmed reason = %q, want %q", got, want) + } + long := strings.Repeat("x", 200) + if got := sanitizeRestartReason(long); len(got) != 80 { + t.Errorf("long reason truncated to %d chars, want 80", len(got)) + } +} + +func TestPaneShowsEmptyInputPrompt(t *testing.T) { + cases := []struct { + name string + pane string + want bool + }{ + {"bare prompt last line", "some output\n" + cliInputPromptMarker + "\n", true}, + {"prompt with trailing blank lines", cliInputPromptMarker + "\n\n \n", true}, + {"prompt with typed text", cliInputPromptMarker + " half-typed command\n", false}, + {"no prompt at all", "compiling...\ndone\n", false}, + {"empty pane", "", false}, + {"prompt above later output", cliInputPromptMarker + "\nnew output after prompt\n", false}, + } + for _, tc := range cases { + if got := paneShowsEmptyInputPrompt(tc.pane); got != tc.want { + t.Errorf("%s: paneShowsEmptyInputPrompt = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestBackendLaunchCmd_OMPPassesModelEffortAndApproval(t *testing.T) { + t.Setenv("HIVE_OMP_APPROVAL_MODE", "") + got := backendLaunchCmd("omp", "openai-codex/gpt-5.6-terra", "omp", false, "xhigh") + want := "omp --approval-mode yolo --model openai-codex/gpt-5.6-terra --thinking xhigh" + if got != want { + t.Fatalf("omp launch = %q, want %q", got, want) + } +} + +func TestBackendLaunchCmd_OMPApprovalModeIsConfigurable(t *testing.T) { + t.Setenv("HIVE_OMP_APPROVAL_MODE", "auto-approve") + got := backendLaunchCmd("omp", "anthropic/claude-opus-5", "omp", false, "") + want := "omp --approval-mode auto-approve --model anthropic/claude-opus-5" + if got != want { + t.Fatalf("omp launch with custom approval = %q, want %q", got, want) + } +} diff --git a/agent/backend_resume.go b/agent/backend_resume.go new file mode 100644 index 0000000..d597712 --- /dev/null +++ b/agent/backend_resume.go @@ -0,0 +1,597 @@ +package agent + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +// Backend-native resume handles (hivecommons/hive#9606). +// +// SessionID/SendResumeKick above only reach a conversation that is still +// live in its tmux pane. A pod restart kills that pane, so the reasoning +// behind a PR the agent opened can only be recovered from whatever the +// backend CLI itself persisted. Several of the CLIs the hive drives do keep +// a transcript on disk under the agent's HOME and can be pointed back at it +// by id (claude --resume, copilot --resume, codex resume, gemini --resume, +// pi --session, omp --resume, goose session --resume --session-id). +// This file captures that id at PR-open time so pkg/prfollowup can store it +// beside the PR's pointer and hand it to whichever session picks the PR up +// later. +// +// Capture is read-only and best effort: it stats the backend's session tree +// and names the newest transcript. Nothing here launches or changes a CLI — +// resuming a whole transcript on every follow-up would also resume its full +// token cost, so the handle is offered to the agent as a command it may run, +// alongside the compact handoff note. + +const ( + // resumeWalkMaxDepth bounds how deep a backend's session tree is walked + // below its root (codex buckets sessions under YYYY/MM/DD). + resumeWalkMaxDepth = 4 + // resumeWalkMaxEntries bounds the directory entries one capture reads, so + // a large history directory cannot stall the PR-opened hook. + resumeWalkMaxEntries = 2000 + // resumeIDMinLen rejects obviously non-id names (a stray "tmp" directory). + resumeIDMinLen = 8 + // resumeIDMaxLen bounds an id read from inside a transcript. + resumeIDMaxLen = 128 + // resumeHeaderMaxBytes bounds how much of a transcript is read to find + // the id recorded in its first line. + resumeHeaderMaxBytes = 64 << 10 + // geminiProjectRootMarker is the file gemini writes in each project's + // temp directory naming the project root it belongs to. + geminiProjectRootMarker = ".project_root" + // gooseLogTailMaxBytes bounds how much of a goose CLI log's tail is read + // to find the session id its tracing spans record. The spans land at the + // end of each completed turn, so the id sits near the end of the file. + gooseLogTailMaxBytes = 256 << 10 + // gooseSessionIDKey is the span attribute goose's per-launch CLI log + // records the conversation id under (verified against goose 1.52.0). + gooseSessionIDKey = `"session.id":"` +) + +// ResumeHandle names a backend-native conversation that can be resumed after +// the authoring tmux pane is gone. +type ResumeHandle struct { + // Backend is the CLI the handle belongs to ("claude", "copilot", ...). + Backend string + // SessionID is the backend's own conversation id. + SessionID string + // Transcript is the file the backend persists that conversation in, so a + // fresh session can read the reasoning even if resume itself fails. For + // goose, whose conversations live in a SQLite store rather than a file + // named by id, it is the per-launch CLI log that names the id instead. + Transcript string + // Command resumes the conversation from a shell in the agent's pane. + Command string + // LastActive is the transcript's modification time when it was captured. + LastActive time.Time + // CapturedAt is when the hive captured the handle (PR-open time); the + // staleness clock runs from here. + CapturedAt time.Time +} + +// backendResumeLayout describes where one backend keeps its transcripts and +// how to turn a path into a resume id and command. +type backendResumeLayout struct { + // root resolves the directory holding the backend's sessions. + root func(home, agentName string) string + // dirSessions is true when each session is a DIRECTORY named by its id + // (copilot), false when each session is a FILE named by its id (claude, + // codex). + dirSessions bool + // transcript is the file inside a session directory that dates it. Only + // meaningful when dirSessions is true. + transcript string + // ext is the transcript file extension for file sessions. + ext string + // id extracts the backend's conversation id from the session file or + // directory name; "" means the name is not a session. + id func(name string) string + // command renders the invocation that resumes id. + command func(id string) string + // idFromFile, when set, reads the real conversation id from the newest + // transcript; id then only selects which files are sessions. Used when + // the file name carries a shortened id (gemini). + idFromFile func(path string) string + // project, when set, keeps only the directories directly under root + // that belong to the agent's working directory. The backend's session + // tree is fleet-shared (a bridged dot-directory) or holds sessions of + // several working directories, so an unscoped walk could name another + // agent's or another checkout's conversation; capture without a working + // directory then yields no handle. + project func(home, dir, workDir string) bool +} + +// backendResumeLayouts holds the backends that expose a capturable resume +// handle. A backend that is absent here simply has no handle: its PRs keep +// the handoff note alone, which is the unchanged #9583 behaviour. +// +// The headless agy runner is deliberately excluded: its conversation id lives +// in a mktemp file that is discarded on every relaunch (agy_turn.go), so a +// captured id would point at a conversation the next launch never reopens. +var backendResumeLayouts = map[string]backendResumeLayout{ + "claude": { + root: func(home, _ string) string { return filepath.Join(home, ".claude", "projects") }, + ext: ".jsonl", + id: func(name string) string { return trimResumeName(name, "", ".jsonl") }, + command: func(id string) string { return "claude --resume " + id }, + }, + "copilot": { + root: func(home, _ string) string { return filepath.Join(home, ".copilot", "session-state") }, + dirSessions: true, + transcript: "events.jsonl", + id: func(name string) string { return trimResumeName(name, "", "") }, + command: func(id string) string { return "copilot --resume " + id }, + }, + "codex": { + root: func(_, agentName string) string { return filepath.Join(codexHomePath(agentName), "sessions") }, + ext: ".jsonl", + id: codexRolloutID, + command: func(id string) string { return "codex resume " + id }, + }, + // gemini keeps ~/.gemini/tmp//chats/session--.jsonl. + // The file name only carries the first 8 characters of the session id; + // the full id is in the transcript's first (metadata) line. + "gemini": { + root: func(home, _ string) string { return filepath.Join(home, ".gemini", "tmp") }, + ext: ".jsonl", + id: func(name string) string { return trimResumeName(name, "session-", ".jsonl") }, + command: func(id string) string { return "gemini --resume " + id }, + idFromFile: geminiSessionID, + project: geminiProjectDir, + }, + // pi keeps ~/.pi/agent/sessions/----/_.jsonl, one bucket + // per working directory, and reopens a session with --session . + "pi": { + root: func(home, _ string) string { return filepath.Join(home, ".pi", "agent", "sessions") }, + ext: ".jsonl", + id: timestampedSessionID, + command: func(id string) string { return "pi --session " + id }, + project: piProjectDir, + }, + // omp (a pi fork) keeps ~/.omp/agent/sessions//_.jsonl, + // bucketed like pi but home-relative for directories under HOME, and + // reopens a session with --resume . + "omp": { + root: func(home, _ string) string { return filepath.Join(home, ".omp", "agent", "sessions") }, + ext: ".jsonl", + id: timestampedSessionID, + command: func(id string) string { return "omp --resume " + id }, + project: ompProjectDir, + }, + // goose keeps its conversations in a SQLite database + // (~/.local/share/goose/sessions/sessions.db), not in a transcript file + // named by id, so the id is read from the per-launch CLI log instead: + // ~/.local/state/goose/logs/cli//.log records the + // conversation id as the "session.id" span attribute at the end of every + // completed turn. The log tree is under the agent's own HOME, so no + // project scoping is needed. A launch whose first turn has not finished + // yet has no id in its log, and then no handle is captured — the PR keeps + // the handoff note alone. Verified against goose 1.52.0, which resumes a + // session with `goose session --resume --session-id `. + "goose": { + root: func(home, _ string) string { + return filepath.Join(home, ".local", "state", "goose", "logs", "cli") + }, + ext: ".log", + id: gooseLaunchLogID, + command: func(id string) string { return "goose session --resume --session-id " + id }, + idFromFile: gooseSessionID, + }, +} + +// BackendSupportsResumeHandle reports whether a backend keeps a transcript the +// hive can capture a resume id from. +func BackendSupportsResumeHandle(backend string) bool { + _, ok := backendResumeLayouts[strings.TrimSpace(backend)] + return ok +} + +// trimResumeName strips a prefix and extension and rejects names too short to +// be a conversation id. +func trimResumeName(name, prefix, ext string) string { + if prefix != "" { + if !strings.HasPrefix(name, prefix) { + return "" + } + name = strings.TrimPrefix(name, prefix) + } + name = strings.TrimSuffix(name, ext) + if len(name) < resumeIDMinLen || strings.HasPrefix(name, ".") { + return "" + } + return name +} + +// codexRolloutID pulls the conversation uuid off a codex rollout file name +// ("rollout-2026-09-30T08-05-47-.jsonl"). The timestamp in the middle +// also contains dashes, so the id is the trailing uuid-shaped run. +func codexRolloutID(name string) string { + base := trimResumeName(name, "rollout-", ".jsonl") + if base == "" { + return "" + } + fields := strings.Split(base, "-") + if len(fields) < 5 { + return "" + } + id := strings.Join(fields[len(fields)-5:], "-") + if !looksLikeUUID(id) { + return "" + } + return id +} + +func looksLikeUUID(s string) bool { + groups := strings.Split(s, "-") + want := []int{8, 4, 4, 4, 12} + if len(groups) != len(want) { + return false + } + for i, g := range groups { + if len(g) != want[i] { + return false + } + for _, r := range g { + if !strings.ContainsRune("0123456789abcdefABCDEF", r) { + return false + } + } + } + return true +} + +// isSafeResumeID reports whether an id read from inside a transcript is safe +// to render into a shell command: a bounded run of [A-Za-z0-9_-]. +func isSafeResumeID(id string) bool { + if len(id) < resumeIDMinLen || len(id) > resumeIDMaxLen { + return false + } + for _, r := range id { + if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')) { + return false + } + } + return true +} + +// geminiSessionID reads the full session id from a gemini transcript's first +// line (its metadata record) and checks it against the shortened id in the +// file name, so a truncated or foreign file never yields a handle. +func geminiSessionID(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer func() { _ = f.Close() }() + line, err := bufio.NewReader(io.LimitReader(f, resumeHeaderMaxBytes)).ReadBytes('\n') + if err != nil && err != io.EOF { + return "" + } + var meta struct { + SessionID string `json:"sessionId"` + } + if json.Unmarshal(line, &meta) != nil || !isSafeResumeID(meta.SessionID) { + return "" + } + short := trimResumeName(filepath.Base(path), "session-", ".jsonl") + if len(short) < 8 || !strings.HasSuffix(short, meta.SessionID[:8]) { + return "" + } + return meta.SessionID +} + +// timestampedSessionID pulls the session id off a pi/omp session file name +// ("2026-09-30T08-05-47-123Z_.jsonl"). Files that do not start with a +// date (omp's per-subagent transcripts, .jsonl) are not sessions, +// and an id that is not a plain [A-Za-z0-9_-] token is never offered because +// it is rendered into a shell command. +func timestampedSessionID(name string) string { + base := strings.TrimSuffix(name, ".jsonl") + ts, id, ok := strings.Cut(base, "_") + if !ok || len(ts) < 11 || ts[4] != '-' || ts[10] != 'T' { + return "" + } + for _, r := range ts[:4] { + if r < '0' || r > '9' { + return "" + } + } + if !isSafeResumeID(id) { + return "" + } + return id +} + +// gooseLaunchLogID selects goose's per-launch CLI log files +// ("20260930_095526.log": an 8-digit date, "_", a 6-digit time). The name is +// the launch timestamp, not the conversation id — gooseSessionID reads the +// real id from inside the newest log. +func gooseLaunchLogID(name string) string { + base := trimResumeName(name, "", ".log") + if len(base) != 15 || base[8] != '_' { + return "" + } + for i, r := range base { + if i == 8 { + continue + } + if r < '0' || r > '9' { + return "" + } + } + return base +} + +// gooseSessionID reads the conversation id from a goose CLI log: the tracing +// spans goose appends at the end of every completed turn carry it as the +// "session.id" attribute. The conversation itself lives in goose's SQLite +// session store, so this log is the only file that names the id. Only the +// tail is read, and the LAST occurrence wins, so a launch that opened more +// than one conversation yields the current one. An id that is not a plain +// [A-Za-z0-9_-] token is never offered because it is rendered into a shell +// command. +func gooseSessionID(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + return "" + } + if info.Size() > gooseLogTailMaxBytes { + if _, err := f.Seek(info.Size()-gooseLogTailMaxBytes, io.SeekStart); err != nil { + return "" + } + } + tail, err := io.ReadAll(io.LimitReader(f, gooseLogTailMaxBytes)) + if err != nil { + return "" + } + at := bytes.LastIndex(tail, []byte(gooseSessionIDKey)) + if at < 0 { + return "" + } + rest := tail[at+len(gooseSessionIDKey):] + end := bytes.IndexByte(rest, '"') + if end < 0 { + return "" + } + id := string(rest[:end]) + if !isSafeResumeID(id) { + return "" + } + return id +} + +// encodeAbsoluteSessionDir is pi's session bucket name for cwd: the path +// without its leading separator, with separators and colons turned into +// dashes, wrapped in "--". +func encodeAbsoluteSessionDir(cwd string) string { + trimmed := strings.TrimLeft(filepath.Clean(cwd), `/\`) + return "--" + strings.NewReplacer("/", "-", `\`, "-", ":", "-").Replace(trimmed) + "--" +} + +// piProjectDir reports whether a bucket under ~/.pi/agent/sessions belongs to +// workDir. pi names the bucket after its process cwd, which the OS reports +// with symlinks resolved, so the canonical path is accepted too. +func piProjectDir(_, dir, workDir string) bool { + name := filepath.Base(dir) + return name == encodeAbsoluteSessionDir(workDir) || name == encodeAbsoluteSessionDir(canonicalPath(workDir)) +} + +// ompProjectDir reports whether a bucket under ~/.omp/agent/sessions belongs +// to workDir. omp resolves symlinks, then names the bucket "-" for +// a directory under HOME, "-tmp-" under the temp root, and pi's +// "----" otherwise (also the name older releases used everywhere). +func ompProjectDir(home, dir, workDir string) bool { + name := filepath.Base(dir) + if name == encodeAbsoluteSessionDir(workDir) { + return true + } + cwd := canonicalPath(workDir) + if name == encodeAbsoluteSessionDir(cwd) { + return true + } + encode := strings.NewReplacer("/", "-", `\`, "-", ":", "-") + relativeName := func(base, prefix string) (string, bool) { + if base == "" { + return "", false + } + rel, err := filepath.Rel(canonicalPath(base), cwd) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return "", false + } + if rel == "." { + return prefix, true + } + if strings.HasSuffix(prefix, "-") { + return prefix + encode.Replace(rel), true + } + return prefix + "-" + encode.Replace(rel), true + } + if want, ok := relativeName(home, "-"); ok { + return name == want + } + if want, ok := relativeName(os.TempDir(), "-tmp"); ok { + return name == want + } + return false +} + +// canonicalPath resolves symlinks in p when it exists, else returns it +// cleaned. +func canonicalPath(p string) string { + p = filepath.Clean(p) + if resolved, err := filepath.EvalSymlinks(p); err == nil { + return resolved + } + return p +} + +// geminiProjectDir reports whether a directory under ~/.gemini/tmp belongs to +// workDir: current gemini names it by a registry slug and records the +// project root in a marker file; older releases named it by the SHA-256 of +// the project root. +func geminiProjectDir(_, dir, workDir string) bool { + workDir = filepath.Clean(workDir) + if raw, err := os.ReadFile(filepath.Join(dir, geminiProjectRootMarker)); err == nil { + return filepath.Clean(strings.TrimSpace(string(raw))) == workDir + } + sum := sha256.Sum256([]byte(workDir)) + return filepath.Base(dir) == hex.EncodeToString(sum[:]) +} + +// CaptureResumeHandle names the newest conversation the agent's backend has +// persisted under its own HOME, or reports false when the backend keeps none +// (or has not written one yet). It never fails loudly: a missing tree, an +// unreadable directory and an unsupported backend are all "no handle", and +// the PR then keeps the handoff note alone. Backends whose session tree is +// scoped by project (gemini, pi, omp) need the agent's working directory; use +// CaptureResumeHandleIn for those. +func CaptureResumeHandle(agentName string, uid int, backend string, now time.Time) (ResumeHandle, bool) { + return CaptureResumeHandleIn(agentName, uid, backend, "", now) +} + +// CaptureResumeHandleIn is CaptureResumeHandle for an agent whose CLI runs in +// workDir. For a project-scoped backend only sessions of that project are +// considered, and an empty workDir yields no handle. +func CaptureResumeHandleIn(agentName string, uid int, backend, workDir string, now time.Time) (ResumeHandle, bool) { + backend = strings.TrimSpace(backend) + layout, ok := backendResumeLayouts[backend] + if !ok { + return ResumeHandle{}, false + } + if layout.project != nil && strings.TrimSpace(workDir) == "" { + return ResumeHandle{}, false + } + home := AgentHome(agentName, uid, backend) + root := layout.root(home, agentName) + if root == "" { + return ResumeHandle{}, false + } + id, path, modTime, ok := newestResumeSession(root, home, workDir, layout) + if !ok { + return ResumeHandle{}, false + } + if layout.idFromFile != nil { + if id = layout.idFromFile(path); id == "" { + return ResumeHandle{}, false + } + } + return ResumeHandle{ + Backend: backend, + SessionID: id, + Transcript: path, + Command: layout.command(id), + LastActive: modTime, + CapturedAt: now, + }, true +} + +// newestResumeSession walks root for the most recently written session and +// returns its id, transcript path and modification time. +func newestResumeSession(root, home, workDir string, layout backendResumeLayout) (id, path string, modTime time.Time, ok bool) { + budget := resumeWalkMaxEntries + var walk func(dir string, depth int) + walk = func(dir string, depth int) { + if depth > resumeWalkMaxDepth || budget <= 0 { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, entry := range entries { + if budget--; budget <= 0 { + return + } + name := entry.Name() + full := filepath.Join(dir, name) + if entry.IsDir() { + if layout.dirSessions { + if sid := layout.id(name); sid != "" { + consider(&id, &path, &modTime, &ok, sid, filepath.Join(full, layout.transcript)) + continue + } + } + if depth == 0 && layout.project != nil && !layout.project(home, full, workDir) { + continue + } + walk(full, depth+1) + continue + } + if layout.dirSessions || !strings.HasSuffix(name, layout.ext) { + continue + } + if sid := layout.id(name); sid != "" { + consider(&id, &path, &modTime, &ok, sid, full) + } + } + } + walk(root, 0) + return id, path, modTime, ok +} + +// consider keeps candidate when its transcript is newer than the best so far. +func consider(id, path *string, modTime *time.Time, ok *bool, candidateID, candidatePath string) { + info, err := os.Stat(candidatePath) + if err != nil || info.IsDir() { + return + } + if *ok && !info.ModTime().After(*modTime) { + return + } + *id, *path, *modTime, *ok = candidateID, candidatePath, info.ModTime(), true +} + +// ResumeHandle captures the backend-native resume handle of the named agent's +// current conversation, or false when the agent is unknown, not running, or +// on a backend with no capturable handle. +func (m *Manager) ResumeHandle(name string) (ResumeHandle, bool) { + if m == nil { + return ResumeHandle{}, false + } + m.mu.RLock() + agent, found := m.agents[name] + if !found || agent == nil || agent.State != StateRunning { + m.mu.RUnlock() + return ResumeHandle{}, false + } + backend, uid := effectiveBackend(agent), agent.UID + workDir := "" + if m.workDir != "" { + workDir = filepath.Join(m.workDir, name) + } + m.mu.RUnlock() + return CaptureResumeHandleIn(name, uid, backend, workDir, time.Now()) +} + +// String renders a handle for logs. +func (h ResumeHandle) String() string { + if h.SessionID == "" { + return "" + } + return fmt.Sprintf("%s session %s (%s)", h.Backend, h.SessionID, h.Transcript) +} + +// TranscriptExists reports whether the captured transcript is still on disk. +func (h ResumeHandle) TranscriptExists() bool { + if h.Transcript == "" { + return false + } + _, err := os.Stat(h.Transcript) + return err == nil +} diff --git a/agent/backend_resume_test.go b/agent/backend_resume_test.go new file mode 100644 index 0000000..a185651 --- /dev/null +++ b/agent/backend_resume_test.go @@ -0,0 +1,467 @@ +package agent + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/hivecommons/crux/config" +) + +// Backend resume-handle capture (hivecommons/hive#9606). Each test lays out +// the backend's real on-disk session tree under a temp HOME and checks the +// captured id, transcript and command. + +func writeResumeTranscript(t *testing.T, path string, modTime time.Time) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte("{}\n"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + if err := os.Chtimes(path, modTime, modTime); err != nil { + t.Fatalf("chtimes %s: %v", path, err) + } +} + +// homeForCapture gives the capture path a temp HOME. AgentHome returns the +// process HOME for an agent with no allocated UID, which is the layout the +// hive uses when it runs as the hive user. +func homeForCapture(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + return home +} + +func TestCaptureResumeHandle_ClaudeNamesNewestTranscript(t *testing.T) { + home := homeForCapture(t) + now := time.Now() + projects := filepath.Join(home, ".claude", "projects") + writeResumeTranscript(t, filepath.Join(projects, "-data-work-hive", "11111111-2222-3333-4444-555555555555.jsonl"), now.Add(-2*time.Hour)) + writeResumeTranscript(t, filepath.Join(projects, "-data-work-other", "99999999-8888-7777-6666-555555555555.jsonl"), now.Add(-time.Minute)) + + h, ok := CaptureResumeHandle("scanner", 0, "claude", now) + if !ok { + t.Fatal("no handle captured for claude") + } + if h.SessionID != "99999999-8888-7777-6666-555555555555" { + t.Errorf("session id = %q, want the newest transcript's", h.SessionID) + } + if h.Command != "claude --resume "+h.SessionID { + t.Errorf("command = %q", h.Command) + } + if !strings.HasSuffix(h.Transcript, ".jsonl") || !h.TranscriptExists() { + t.Errorf("transcript = %q, want an existing .jsonl", h.Transcript) + } + if h.Backend != "claude" || !h.CapturedAt.Equal(now) { + t.Errorf("handle = %+v, want backend claude captured at now", h) + } +} + +func TestCaptureResumeHandle_CopilotUsesSessionDirectories(t *testing.T) { + home := homeForCapture(t) + now := time.Now() + root := filepath.Join(home, ".copilot", "session-state") + writeResumeTranscript(t, filepath.Join(root, "sess-old-0001", "events.jsonl"), now.Add(-time.Hour)) + writeResumeTranscript(t, filepath.Join(root, "sess-new-0002", "events.jsonl"), now) + + h, ok := CaptureResumeHandle("scanner", 0, "copilot", now) + if !ok { + t.Fatal("no handle captured for copilot") + } + if h.SessionID != "sess-new-0002" { + t.Errorf("session id = %q, want the newest session directory", h.SessionID) + } + if h.Command != "copilot --resume sess-new-0002" { + t.Errorf("command = %q", h.Command) + } + if filepath.Base(h.Transcript) != "events.jsonl" { + t.Errorf("transcript = %q, want the session's events.jsonl", h.Transcript) + } +} + +func TestCaptureResumeHandle_CodexReadsDateBucketedRollouts(t *testing.T) { + homeForCapture(t) + now := time.Now() + codexHome := t.TempDir() + prev := codexHomePrefix + codexHomePrefix = filepath.Join(codexHome, "codex-") + t.Cleanup(func() { codexHomePrefix = prev }) + sessions := filepath.Join(codexHomePath("scanner"), "sessions", "2026", "09", "30") + writeResumeTranscript(t, filepath.Join(sessions, "rollout-2026-09-30T08-05-47-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl"), now) + // Not a rollout file: must not be mistaken for a conversation. + writeResumeTranscript(t, filepath.Join(sessions, "notes.jsonl"), now.Add(time.Hour)) + + h, ok := CaptureResumeHandle("scanner", 0, "codex", now) + if !ok { + t.Fatal("no handle captured for codex") + } + if h.SessionID != "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" { + t.Errorf("session id = %q, want the rollout uuid", h.SessionID) + } + if h.Command != "codex resume "+h.SessionID { + t.Errorf("command = %q", h.Command) + } +} + +func TestCaptureResumeHandle_UnsupportedAndEmptyBackends(t *testing.T) { + homeForCapture(t) + now := time.Now() + for _, backend := range []string{"agy", "bob", "", "vllm"} { + if BackendSupportsResumeHandle(backend) { + t.Errorf("backend %q unexpectedly claims a capturable resume handle", backend) + } + if _, ok := CaptureResumeHandle("scanner", 0, backend, now); ok { + t.Errorf("backend %q captured a handle", backend) + } + } + // A supported backend that has simply never written a transcript. + if _, ok := CaptureResumeHandle("scanner", 0, "claude", now); ok { + t.Error("claude captured a handle with no session tree on disk") + } +} + +// writeGeminiTranscript writes a gemini session file whose first line is the +// metadata record carrying sessionID. +func writeGeminiTranscript(t *testing.T, path, sessionID string, modTime time.Time) { + t.Helper() + writeResumeTranscript(t, path, modTime) + body := `{"sessionId":"` + sessionID + `","projectHash":"x"}` + "\n" + `{"type":"user"}` + "\n" + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + if err := os.Chtimes(path, modTime, modTime); err != nil { + t.Fatalf("chtimes %s: %v", path, err) + } +} + +func writeGeminiProjectMarker(t *testing.T, projectDir, root string) { + t.Helper() + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", projectDir, err) + } + if err := os.WriteFile(filepath.Join(projectDir, geminiProjectRootMarker), []byte(root+"\n"), 0o644); err != nil { + t.Fatalf("write marker: %v", err) + } +} + +func TestCaptureResumeHandle_GeminiScopesToAgentProject(t *testing.T) { + home := homeForCapture(t) + now := time.Now() + tmp := filepath.Join(home, ".gemini", "tmp") + const ownID = "aaaaaaaa-1111-2222-3333-444444444444" + const otherID = "bbbbbbbb-5555-6666-7777-888888888888" + writeGeminiProjectMarker(t, filepath.Join(tmp, "scanner"), "/data/agents/scanner") + writeGeminiTranscript(t, filepath.Join(tmp, "scanner", "chats", "session-2026-09-30T08-00-aaaaaaaa.jsonl"), ownID, now.Add(-time.Hour)) + // ~/.gemini is fleet-shared: a newer session of another agent's project + // must never be offered as this agent's conversation. + writeGeminiProjectMarker(t, filepath.Join(tmp, "reviewer"), "/data/agents/reviewer") + writeGeminiTranscript(t, filepath.Join(tmp, "reviewer", "chats", "session-2026-09-30T09-00-bbbbbbbb.jsonl"), otherID, now) + + h, ok := CaptureResumeHandleIn("scanner", 0, "gemini", "/data/agents/scanner", now) + if !ok { + t.Fatal("no handle captured for gemini") + } + if h.SessionID != ownID { + t.Errorf("session id = %q, want the full id from the agent's own project", h.SessionID) + } + if h.Command != "gemini --resume "+ownID { + t.Errorf("command = %q", h.Command) + } + if !h.TranscriptExists() || !strings.Contains(h.Transcript, filepath.Join("scanner", "chats")) { + t.Errorf("transcript = %q", h.Transcript) + } + if !BackendSupportsResumeHandle("gemini") { + t.Error("gemini does not claim a capturable resume handle") + } +} + +func TestCaptureResumeHandle_GeminiLegacyHashedProjectDir(t *testing.T) { + home := homeForCapture(t) + now := time.Now() + sum := sha256.Sum256([]byte("/data/agents/scanner")) + const id = "cccccccc-1111-2222-3333-444444444444" + writeGeminiTranscript(t, filepath.Join(home, ".gemini", "tmp", hex.EncodeToString(sum[:]), "chats", "session-2026-09-30T08-00-cccccccc.jsonl"), id, now) + + h, ok := CaptureResumeHandleIn("scanner", 0, "gemini", "/data/agents/scanner/", now) + if !ok || h.SessionID != id { + t.Fatalf("handle = %+v, ok = %v; want %s from the hashed project dir", h, ok, id) + } +} + +func TestCaptureResumeHandle_GeminiNeedsWorkDirAndValidMetadata(t *testing.T) { + home := homeForCapture(t) + now := time.Now() + project := filepath.Join(home, ".gemini", "tmp", "scanner") + writeGeminiProjectMarker(t, project, "/data/agents/scanner") + writeGeminiTranscript(t, filepath.Join(project, "chats", "session-2026-09-30T08-00-dddddddd.jsonl"), "dddddddd-1111-2222-3333-444444444444", now) + + if _, ok := CaptureResumeHandle("scanner", 0, "gemini", now); ok { + t.Error("gemini captured a handle without a working directory to scope it") + } + if _, ok := CaptureResumeHandleIn("scanner", 0, "gemini", "/data/agents/other", now); ok { + t.Error("gemini captured a handle from a different project") + } + + // The newest transcript's id does not match its file name, or is unsafe + // to render into a command: no handle rather than a wrong one. + for _, bad := range []string{"eeeeeeee-1111-2222-3333-444444444444", "dddddddd; rm -rf /", ""} { + writeGeminiTranscript(t, filepath.Join(project, "chats", "session-2026-09-30T09-00-dddddddd.jsonl"), bad, now.Add(time.Minute)) + if h, ok := CaptureResumeHandleIn("scanner", 0, "gemini", "/data/agents/scanner", now); ok { + t.Errorf("sessionId %q produced handle %+v", bad, h) + } + } +} + +func TestGeminiSessionID_RejectsUnreadableAndMalformed(t *testing.T) { + dir := t.TempDir() + if id := geminiSessionID(filepath.Join(dir, "missing.jsonl")); id != "" { + t.Errorf("missing file yielded id %q", id) + } + path := filepath.Join(dir, "session-2026-09-30T08-00-ffffffff.jsonl") + if err := os.WriteFile(path, []byte("not json\n"), 0o644); err != nil { + t.Fatal(err) + } + if id := geminiSessionID(path); id != "" { + t.Errorf("malformed metadata yielded id %q", id) + } +} + +func TestCodexRolloutID_RejectsNonUUIDNames(t *testing.T) { + for _, name := range []string{"rollout-2026-09-30T08-05-47-not-a-uuid.jsonl", "rollout.jsonl", "events.jsonl", "rollout-abc.jsonl"} { + if id := codexRolloutID(name); id != "" { + t.Errorf("codexRolloutID(%q) = %q, want no id", name, id) + } + } +} + +func TestManagerResumeHandle_NilAndUnknownAgent(t *testing.T) { + var m *Manager + if _, ok := m.ResumeHandle("scanner"); ok { + t.Error("nil manager returned a resume handle") + } + mgr := &Manager{agents: map[string]*AgentProcess{}} + if _, ok := mgr.ResumeHandle("scanner"); ok { + t.Error("unknown agent returned a resume handle") + } + mgr.agents["scanner"] = &AgentProcess{Name: "scanner", State: StateStopped} + if _, ok := mgr.ResumeHandle("scanner"); ok { + t.Error("stopped agent returned a resume handle") + } +} + +func TestManagerResumeHandle_GeminiUsesAgentWorkDir(t *testing.T) { + home := homeForCapture(t) + workRoot := t.TempDir() + const id = "12345678-1111-2222-3333-444444444444" + project := filepath.Join(home, ".gemini", "tmp", "scanner") + writeGeminiProjectMarker(t, project, filepath.Join(workRoot, "scanner")) + writeGeminiTranscript(t, filepath.Join(project, "chats", "session-2026-09-30T08-00-12345678.jsonl"), id, time.Now()) + mgr := &Manager{workDir: workRoot, agents: map[string]*AgentProcess{ + "scanner": {Name: "scanner", State: StateRunning, Config: config.AgentConfig{Backend: "gemini"}}, + }} + + h, ok := mgr.ResumeHandle("scanner") + if !ok || h.SessionID != id || h.Backend != "gemini" { + t.Fatalf("handle = %+v, ok = %v", h, ok) + } +} + +func TestManagerResumeHandle_RunningAgentCapturesBackendSession(t *testing.T) { + home := homeForCapture(t) + writeResumeTranscript(t, filepath.Join(home, ".copilot", "session-state", "sess-live-0003", "events.jsonl"), time.Now()) + mgr := &Manager{agents: map[string]*AgentProcess{ + "scanner": {Name: "scanner", State: StateRunning, Config: config.AgentConfig{Backend: "copilot"}}, + }} + + h, ok := mgr.ResumeHandle("scanner") + if !ok { + t.Fatal("running copilot agent produced no resume handle") + } + if h.SessionID != "sess-live-0003" || h.Backend != "copilot" { + t.Errorf("handle = %+v", h) + } + if h.String() == "" { + t.Error("String() is empty for a captured handle") + } +} + +func TestCaptureResumeHandle_PiScopesToAgentBucket(t *testing.T) { + home := homeForCapture(t) + now := time.Now() + sessions := filepath.Join(home, ".pi", "agent", "sessions") + const ownID = "aaaaaaaa-1111-2222-3333-444444444444" + const otherID = "bbbbbbbb-5555-6666-7777-888888888888" + writeResumeTranscript(t, filepath.Join(sessions, "--data-agents-scanner--", "2026-09-30T08-05-47-123Z_"+ownID+".jsonl"), now.Add(-time.Hour)) + // A newer session of another working directory must not be offered. + writeResumeTranscript(t, filepath.Join(sessions, "--data-agents-reviewer--", "2026-09-30T09-05-47-123Z_"+otherID+".jsonl"), now) + // Not a session file: no timestamp prefix. + writeResumeTranscript(t, filepath.Join(sessions, "--data-agents-scanner--", "notes_"+otherID+".jsonl"), now) + + h, ok := CaptureResumeHandleIn("scanner", 0, "pi", "/data/agents/scanner", now) + if !ok { + t.Fatal("no handle captured for pi") + } + if h.SessionID != ownID { + t.Errorf("session id = %q, want the agent's own bucket's session", h.SessionID) + } + if h.Command != "pi --session "+ownID { + t.Errorf("command = %q", h.Command) + } + if !h.TranscriptExists() || !BackendSupportsResumeHandle("pi") { + t.Errorf("transcript = %q, supported = %v", h.Transcript, BackendSupportsResumeHandle("pi")) + } + if _, ok := CaptureResumeHandle("scanner", 0, "pi", now); ok { + t.Error("pi captured a handle without a working directory to scope it") + } +} + +func TestCaptureResumeHandle_OmpHomeRelativeAndAbsoluteBuckets(t *testing.T) { + home := homeForCapture(t) + now := time.Now() + sessions := filepath.Join(home, ".omp", "agent", "sessions") + const homeID = "cccccccc-1111-2222-3333-444444444444" + const absID = "dddddddd-1111-2222-3333-444444444444" + const otherID = "eeeeeeee-1111-2222-3333-444444444444" + + workInHome := filepath.Join(home, "work", "scanner") + if err := os.MkdirAll(workInHome, 0o755); err != nil { + t.Fatal(err) + } + writeResumeTranscript(t, filepath.Join(sessions, "-work-scanner", "2026-09-30T08-00-00-000Z_"+homeID+".jsonl"), now.Add(-time.Hour)) + writeResumeTranscript(t, filepath.Join(sessions, "-work-reviewer", "2026-09-30T09-00-00-000Z_"+otherID+".jsonl"), now) + // A per-subagent transcript inside the session directory is not a session. + writeResumeTranscript(t, filepath.Join(sessions, "-work-scanner", "2026-09-30T08-00-00-000Z_"+homeID, "Explorer.jsonl"), now) + + h, ok := CaptureResumeHandleIn("scanner", 0, "omp", workInHome, now) + if !ok { + t.Fatal("no handle captured for omp") + } + if h.SessionID != homeID || h.Command != "omp --resume "+homeID { + t.Errorf("handle = %+v, want %s from the home-relative bucket", h, homeID) + } + + writeResumeTranscript(t, filepath.Join(sessions, "--data-agents-scanner--", "2026-09-30T08-00-00-000Z_"+absID+".jsonl"), now) + h, ok = CaptureResumeHandleIn("scanner", 0, "omp", "/data/agents/scanner", now) + if !ok || h.SessionID != absID { + t.Fatalf("handle = %+v, ok = %v; want %s from the absolute bucket", h, ok, absID) + } + if !BackendSupportsResumeHandle("omp") { + t.Error("omp does not claim a capturable resume handle") + } +} + +func TestTimestampedSessionID_RejectsNonSessionNames(t *testing.T) { + for _, name := range []string{ + "Explorer.jsonl", + "notes_aaaaaaaa-1111.jsonl", + "2026-09-30T08-00-00-000Z.jsonl", + "2026-09-30T08-00-00-000Z_short.jsonl", + "2026-09-30T08-00-00-000Z_bad;id-12345.jsonl", + "abcd-09-30T08-00-00-000Z_aaaaaaaa-1111.jsonl", + } { + if id := timestampedSessionID(name); id != "" { + t.Errorf("timestampedSessionID(%q) = %q, want no id", name, id) + } + } + if id := timestampedSessionID("2026-09-30T08-00-00-000Z_aaaaaaaa-1111.jsonl"); id != "aaaaaaaa-1111" { + t.Errorf("id = %q", id) + } +} + +// writeGooseLog writes a goose per-launch CLI log whose tracing spans carry +// sessionIDs in order, mirroring goose 1.52.0's +// ~/.local/state/goose/logs/cli//.log layout. +func writeGooseLog(t *testing.T, path string, modTime time.Time, sessionIDs ...string) { + t.Helper() + body := `{"timestamp":"2026-09-30T13:55:26Z","level":"INFO","fields":{"message":"CLI command executed","command":"run"},"target":"goose_cli::cli"}` + "\n" + for _, id := range sessionIDs { + body += `{"spans":[{"gen_ai.agent.name":"goose","session.host":"hive","session.id":"` + id + `","name":"reply"}]}` + "\n" + } + writeResumeTranscript(t, path, modTime) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + if err := os.Chtimes(path, modTime, modTime); err != nil { + t.Fatalf("chtimes %s: %v", path, err) + } +} + +func TestCaptureResumeHandle_GooseReadsSessionIDFromNewestCLILog(t *testing.T) { + home := homeForCapture(t) + now := time.Now() + logs := filepath.Join(home, ".local", "state", "goose", "logs", "cli") + writeGooseLog(t, filepath.Join(logs, "2026-09-29", "20260929_080547.log"), now.Add(-25*time.Hour), "20260929_1") + writeGooseLog(t, filepath.Join(logs, "2026-09-30", "20260930_095526.log"), now, "20260930_1", "20260930_2") + // Not a launch log: must never be mistaken for a session. + writeGooseLog(t, filepath.Join(logs, "2026-09-30", "install.log"), now.Add(time.Minute), "20260930_9") + + h, ok := CaptureResumeHandle("scanner", 0, "goose", now) + if !ok { + t.Fatal("no handle captured for goose") + } + if h.SessionID != "20260930_2" { + t.Errorf("session id = %q, want the newest log's last session id", h.SessionID) + } + if h.Command != "goose session --resume --session-id 20260930_2" { + t.Errorf("command = %q", h.Command) + } + if !h.TranscriptExists() || filepath.Base(h.Transcript) != "20260930_095526.log" { + t.Errorf("transcript = %q, want the newest launch log", h.Transcript) + } + if !BackendSupportsResumeHandle("goose") { + t.Error("goose does not claim a capturable resume handle") + } +} + +func TestCaptureResumeHandle_GooseNeedsACompletedTurnAndASafeID(t *testing.T) { + home := homeForCapture(t) + now := time.Now() + logs := filepath.Join(home, ".local", "state", "goose", "logs", "cli", "2026-09-30") + + // A launch whose first turn has not finished has no session id in its + // log yet: no handle rather than a stale or foreign one. + writeGooseLog(t, filepath.Join(logs, "20260930_100000.log"), now) + if h, ok := CaptureResumeHandle("scanner", 0, "goose", now); ok { + t.Errorf("id-less log produced handle %+v", h) + } + + // An id that is unsafe to render into a shell command is never offered. + writeGooseLog(t, filepath.Join(logs, "20260930_100100.log"), now.Add(time.Minute), "20260930_1; rm -rf /") + if h, ok := CaptureResumeHandle("scanner", 0, "goose", now); ok { + t.Errorf("unsafe id produced handle %+v", h) + } +} + +func TestGooseLaunchLogID_SelectsOnlyLaunchLogs(t *testing.T) { + for _, name := range []string{"install.log", "20260930.log", "2026-09-30_095526.log", "20260930_0955.log", "20260930_09552a.log", "20260930_095526.txt"} { + if id := gooseLaunchLogID(name); id != "" { + t.Errorf("gooseLaunchLogID(%q) = %q, want no id", name, id) + } + } + if id := gooseLaunchLogID("20260930_095526.log"); id != "20260930_095526" { + t.Errorf("id = %q", id) + } +} + +func TestGooseSessionID_ReadsTailOfLargeLog(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "20260930_095526.log") + filler := strings.Repeat(`{"timestamp":"2026-09-30T13:55:26Z","level":"DEBUG","fields":{"message":"x"}}`+"\n", 8192) + body := filler + `{"spans":[{"session.id":"20260930_7","name":"reply"}]}` + "\n" + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if id := gooseSessionID(path); id != "20260930_7" { + t.Errorf("id = %q, want the id from the log's tail", id) + } + if id := gooseSessionID(filepath.Join(dir, "missing.log")); id != "" { + t.Errorf("missing file yielded id %q", id) + } +} diff --git a/agent/blocking_prompt_test.go b/agent/blocking_prompt_test.go new file mode 100644 index 0000000..13be801 --- /dev/null +++ b/agent/blocking_prompt_test.go @@ -0,0 +1,363 @@ +package agent + +import "testing" + +// codexUpdatePane is the real codex 0.146.0 update menu, verbatim. Note the +// PRE-SELECTED option ("›" marker) is "1. Update now" — the destructive one. +const codexUpdatePane = ` ✨ Update available! 0.146.0 -> 0.147.0 + Release notes: https://github.com/openai/codex/releases/latest +› 1. Update now (runs ` + "`npm install -g @openai/codex`" + `) + 2. Skip + 3. Skip until next version + Press enter to continue` + +const codexUpdatePane0156 = ` Update available · 0.156.1 → 0.157.0 + Release notes: https://github.com/openai/codex/releases/latest +› 1. Update now (runs ` + "`npm install -g @openai/codex`" + `) + 2. Skip + 3. Skip until next version + enter continue · esc skip` + +const codexCommandApprovalPane = ` Would you like to run the following command? + Environment: local + Reason: May I read the latest local kick, summary, and health bead files to report current pipeline status? + $ cat beads/supervisor-kick-architect.json beads/supervisor-summary-architect.json +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with ` + "`cat beads/`" + ` + 3. No, and tell Codex what to do differently (esc)` + +const codexTrustPane = `> You are in /data/agents/architect + Do you trust the contents of this directory? Working with untrusted contents + comes with higher risk of prompt injection. +› 1. Yes, continue + 2. No, quit + Press enter to continue` + +const codexFolderAccessPane = ` Folder access + /data/agents/ci-maintainer + Trust this folder? Codex can read, edit, and run files here, subject to your permission settings. Folder settings can run code automatically, even w… +› 1. Trust and continue + 2. Quit + enter continue · esc quit` + +const copilotTrustPane = `Confirm folder trust + 1. Yes + 2. Yes, and remember for future sessions + 3. No` + +const claudeWorkspaceTrustPane = ` Accessing workspace: + + /data/agents/guide + + Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source project, or work from your team). If not, take a moment to review what's in this folder first. + + Claude Code'll be able to read, edit, and execute files here. + + ❯ No, exit + Yes, I trust this folder + + Enter to confirm · Esc to cancel` + +// TestBlockingPromptKey_CodexUpdateSkipsInsteadOfInstalling is the whole point +// of this mechanism. +// +// codex's update menu pre-selects "1. Update now", which runs +// `npm install -g @openai/codex` as the unprivileged agent UID. That fails with +// EACCES and kills the CLI — on EVERY launch, indefinitely. A bare Enter, or any +// heuristic that merely avoids options containing "no"/"exit", picks exactly +// that option. Only "3. Skip until next version" both unblocks startup and +// persists so the prompt does not return on the next launch. +func TestBlockingPromptKey_CodexUpdateSkipsInsteadOfInstalling(t *testing.T) { + for _, pane := range []string{codexUpdatePane, codexUpdatePane0156} { + key, label, ok := blockingPromptKey("codex", pane) + if !ok { + t.Fatal("codex update prompt was not recognised; the agent would block on it until timeout") + } + if key == "1" { + t.Fatal("answered \"1. Update now\" — this runs npm install -g as the agent UID and kills the CLI") + } + if key != "3" { + t.Errorf("key = %q, want \"3\" (Skip until next version); %q does not persist across launches", key, key) + } + if label == "" { + t.Error("label is empty; the audit log would not say which prompt was answered") + } + } +} + +func TestBlockingPromptKey_CodexCommandApprovalProceeds(t *testing.T) { + key, label, ok := blockingPromptKey("codex", codexCommandApprovalPane) + if !ok { + t.Fatal("codex command approval prompt was not recognised; the agent would look idle while blocked") + } + if key != "1" { + t.Errorf("key = %q, want \"1\" (Yes, proceed)", key) + } + if label == "" { + t.Error("label is empty; the audit log would not say which prompt was answered") + } +} + +func TestBlockingPromptKey_KnownPrompts(t *testing.T) { + cases := []struct { + name string + backend string + pane string + want string + }{ + {"codex directory trust", "codex", codexTrustPane, "1"}, + {"codex folder access trust", "codex", codexFolderAccessPane, "1"}, + {"claude workspace trust", "claude", claudeWorkspaceTrustPane, "Down"}, + // "1" (session-only), NOT "2" (remember): remembering makes the CLI + // rewrite the shared config.json from its stale in-memory snapshot, + // stomping other agents' state — see blockingPrompts. + {"copilot folder trust", "copilot", copilotTrustPane, "1"}, + {"codex update", "codex", codexUpdatePane, "3"}, + {"codex update 0.156", "codex", codexUpdatePane0156, "3"}, + {"codex command approval", "codex", codexCommandApprovalPane, "1"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + key, _, ok := blockingPromptKey(c.backend, c.pane) + if !ok { + t.Fatalf("prompt not recognised") + } + if key != c.want { + t.Errorf("key = %q, want %q", key, c.want) + } + }) + } +} + +// TestBlockingPromptKey_IgnoresUnrelatedPanes: a prompt is only answered when +// positively identified. Firing a numbered keystroke at an arbitrary pane would +// type stray input into a working agent's prompt. +func TestBlockingPromptKey_IgnoresUnrelatedPanes(t *testing.T) { + panes := []string{ + "", + "? for shortcuts", + "⏵⏵ bypass permissions on (shift+tab to cycle)", + "› Implement {feature}\n gpt-5.6-sol default · /data/agents/architect", + // Superficially similar but NOT the update menu: no options rendered, + // so there is nothing to answer. + "Update available! 0.146.0 -> 0.147.0", + } + for _, p := range panes { + if key, _, ok := blockingPromptKey("codex", p); ok { + t.Errorf("pane %q was answered with %q; want no action", p, key) + } + } +} + +// TestBackendHasBlockingPrompts_GateMatchesTable is the guard for the bug that +// made this whole mechanism inert: the watcher was gated on +// `backend == "copilot"`, so codex agents were never rescued from their update +// menu no matter how well the menu was understood. Deriving the gate from the +// table means a prompt added for a new backend enables the watcher for it. +func TestBackendHasBlockingPrompts_GateMatchesTable(t *testing.T) { + for _, p := range blockingPrompts { + if !backendHasBlockingPrompts(p.backend) { + t.Errorf("backend %q has a blocking prompt but the watcher gate excludes it", p.backend) + } + } + if !backendHasBlockingPrompts("codex") { + t.Error("codex must run the watcher: its update menu kills the CLI on every launch") + } + if !backendHasBlockingPrompts("claude") { + t.Error("claude must run the watcher: its workspace trust menu exits the CLI by default") + } +} + +// TestBlockingPromptKey_BackendScoped: a prompt must only be answered for the +// backend that renders it, so a stray keystroke is never typed into another +// CLI's pane that happens to contain similar words. +func TestBlockingPromptKey_BackendScoped(t *testing.T) { + if _, _, ok := blockingPromptKey("claude", codexUpdatePane); ok { + t.Error("codex update prompt matched while running claude") + } + if _, _, ok := blockingPromptKey("codex", copilotTrustPane); ok { + t.Error("copilot trust prompt matched while running codex") + } +} + +// TestBlockingPromptKey_IgnoresScrollback is the guard for a bug seen live: +// captureTmuxPaneForAgent returns scrollback, so a dead CLI's update menu +// stayed in history and the watcher typed "3" into the bash shell that replaced +// it, on every poll, indefinitely. Only the tail of the pane may be matched. +func TestBlockingPromptKey_IgnoresScrollback(t *testing.T) { + deadCLIPane := codexUpdatePane + ` +npm error The operation was rejected by your operating system. +Error: ` + "`npm install -g @openai/codex`" + ` failed with status exit status: 243 +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$ +hive-ci-maintainer@pod:/data/agents/ci-maintainer$` + if key, _, ok := blockingPromptKey("codex", deadCLIPane); ok { + t.Errorf("answered %q against scrollback of a dead CLI; keystrokes would go to the shell", key) + } + // The same prompt, still live at the bottom, must still be answered. + if _, _, ok := blockingPromptKey("codex", "some earlier output\n"+codexUpdatePane); !ok { + t.Error("a live prompt at the tail was not recognised") + } +} + +const agyTrustPane = ` ▄▀▀▄ Antigravity CLI 1.1.13 + ▀▀▀▀▀▀ user@example.com (Google AI Pro) + Accessing workspace: + /data/agents/guide + Do you trust the contents of this project? + Antigravity CLI requires permission to read, edit, and execute files here. + > Yes, I trust this folder + No, exit + ↑/↓ Navigate · enter Confirm` + +// TestBlockingPromptKey_AgyProjectTrust: agy blocks startup on a project-trust +// menu whose affirmative option is ALREADY selected, so the answer is a bare +// Enter and the key must be empty — typing a digit there is stray input, not a +// selection. Without this entry an agent rotated onto agy hangs at startup, +// which matters now that automated rotation can move agents to Google. +func TestBlockingPromptKey_AgyProjectTrust(t *testing.T) { + key, label, ok := blockingPromptKey("agy", agyTrustPane) + if !ok { + t.Fatal("agy project-trust prompt not recognised; a rotated agent would hang on it") + } + if key != "" { + t.Errorf("key = %q, want \"\" (Enter alone; the affirmative option is preselected)", key) + } + if label == "" { + t.Error("label is empty; the audit log would not say which prompt was answered") + } + if !backendHasBlockingPrompts("agy") { + t.Error("agy has a table entry but the watcher gate excludes it") + } +} + +// The agy pane must not be answered while running another backend. +func TestBlockingPromptKey_AgyScoped(t *testing.T) { + for _, b := range []string{"codex", "copilot", "claude"} { + if _, _, ok := blockingPromptKey(b, agyTrustPane); ok { + t.Errorf("agy trust prompt matched while running %q", b) + } + } +} + +func TestBlockingPromptKey_ClaudeWorkspaceTrustTailOnly(t *testing.T) { + key, label, ok := blockingPromptKey("claude", claudeWorkspaceTrustPane) + if !ok { + t.Fatal("claude workspace trust prompt not recognised; the agent would exit and leave bash") + } + if key != "Down" { + t.Errorf("key = %q, want Down then Enter to avoid the default No/exit selection", key) + } + if label == "" { + t.Error("label is empty; the audit log would not say which prompt was answered") + } + + deadCLIPane := claudeWorkspaceTrustPane + ` +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$ +hive-guide@pod:/data/agents/guide$` + if key, _, ok := blockingPromptKey("claude", deadCLIPane); ok { + t.Errorf("answered %q against scrollback of a dead Claude CLI; keystrokes would go to the shell", key) + } +} + +// claudeAutoModeNoticePane is the auto-mode classifier notice verbatim from +// Mike Spreitzer's FMA hive pane (#9945, 2026-10-15). It appears mid-run at +// the first checked tool call and holds that action until Enter is pressed. +const claudeAutoModeNoticePane = `● Checking GitHub CLI authentication and repo identity + ⎿ $ gh auth status 2>&1 | head -10; echo "---"; gh repo view --json nameWithOwner,defaultBranchRef -q '.nameWithOwner' + We're changing auto mode to no longer charge for classifier requests in Claude Code. + However, this session isn't eligible because your requests go through 127.0.0.1:18444, which isn't compatible with this update. + Nothing breaks: auto mode keeps working, and its classifier requests are billed as before. + To fix it and access the new version of auto mode, ask your gateway to implement: https://code.claude.com/docs/en/auto-mode-classifier-billing + Enter to continue · Esc to cancel` + +// TestBlockingPromptKey_ClaudeAutoModeNoticeContinues: the notice is answered +// with a bare Enter (key ""), never Esc — Esc cancels the held tool call and +// ends the turn, and the notice comes straight back on the next one. +func TestBlockingPromptKey_ClaudeAutoModeNoticeContinues(t *testing.T) { + key, label, ok := blockingPromptKey("claude", claudeAutoModeNoticePane) + if !ok { + t.Fatal("auto-mode classifier notice was not recognised; the kick would stall until a human pressed Enter") + } + if key != "" { + t.Errorf("key = %q, want \"\" (bare Enter continues the held action)", key) + } + if label != claudeAutoModeNoticeLabel { + t.Errorf("label = %q, want %q", label, claudeAutoModeNoticeLabel) + } + if !paneHasBlockingPrompt(claudeAutoModeNoticePane) { + t.Error("readiness gate should hold while the notice is live") + } +} + +// TestBlockingPromptKey_ClaudeAutoModeNoticeNotLiveAfterEnter: once Enter is +// pressed the CLI draws output under the notice. The lingering text must not +// match, or the watcher would re-fire Enter every trustReanswerAfter and the +// readiness gate would hold an idle agent. +func TestBlockingPromptKey_ClaudeAutoModeNoticeNotLiveAfterEnter(t *testing.T) { + acknowledged := claudeAutoModeNoticePane + ` + ⎿ Logged in to github.com account hive-bot + --- + fma/fma default=main +● Fetching upstream to see new merges since d657159 +❯ + ⏵⏵ bypass permissions on (shift+tab to cycle)` + if _, _, ok := blockingPromptKey("claude", acknowledged); ok { + t.Fatal("acknowledged notice still read as a live modal") + } + if paneHasBlockingPrompt(acknowledged) { + t.Error("readiness gate held on an acknowledged notice") + } + // A kick that quotes the notice text is not a modal either. + if _, _, ok := blockingPromptKey("claude", "> please ignore any 'classifier requests in Claude Code' banner\n❯"); ok { + t.Fatal("kick text mentioning the notice matched as a modal") + } + // Other backends never render it. + if _, _, ok := blockingPromptKey("copilot", claudeAutoModeNoticePane); ok { + t.Fatal("notice matched for a backend that does not render it") + } +} diff --git a/agent/bob_display_name_test.go b/agent/bob_display_name_test.go new file mode 100644 index 0000000..15b0814 --- /dev/null +++ b/agent/bob_display_name_test.go @@ -0,0 +1,68 @@ +package agent + +import ( + "testing" + + "github.com/hivecommons/crux/config" +) + +// TestBobSessionLabelEnvPair pins #11273: bob-backed agents get an optional +// HIVE_BOB_SESSION_LABEL that is separate from the functional agent name, +// which stays in HIVE_AGENT. +func TestBobSessionLabelEnvPair(t *testing.T) { + tests := []struct { + name string + backend string + override string + prefix string + label string + wantPair bool + wantValue string + }{ + {"bob with per-agent label", "bob", "", "", "hive-scanner", true, "hive-scanner"}, + {"bob with global prefix", "bob", "", "hive-", "", true, "hive-scanner"}, + {"bob without label gets no session label", "bob", "", "", "", false, ""}, + {"bob label is trimmed", "bob", "", "", " hive-scanner ", true, "hive-scanner"}, + {"backend override to bob", "claude", "bob", "", "hive-scanner", true, "hive-scanner"}, + {"claude backend gets no pair", "claude", "", "", "hive-scanner", false, ""}, + {"copilot backend gets no pair", "copilot", "", "", "", false, ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := &Manager{logger: discardLogger(), project: ProjectContext{BobSessionPrefix: tc.prefix}} + agent := &AgentProcess{ + Name: "scanner", + BackendOverride: tc.override, + Config: config.AgentConfig{Backend: tc.backend, Bob: config.AgentBobConfig{SessionLabel: tc.label}}, + } + + var found *agentEnvPair + var hiveAgent string + for _, p := range m.agentEnvPairs(agent) { + switch p.Key { + case "HIVE_BOB_SESSION_LABEL": + pair := p + found = &pair + case "HIVE_AGENT": + hiveAgent = p.Value + } + } + if hiveAgent != "scanner" { + t.Errorf("HIVE_AGENT = %q, want functional name %q", hiveAgent, "scanner") + } + if tc.wantPair != (found != nil) { + t.Fatalf("HIVE_BOB_DISPLAY_NAME present = %v, want %v", found != nil, tc.wantPair) + } + if found == nil { + return + } + if found.Value != tc.wantValue { + t.Errorf("HIVE_BOB_SESSION_LABEL = %q, want %q", found.Value, tc.wantValue) + } + if found.Secret { + t.Error("HIVE_BOB_SESSION_LABEL must not be Secret: it is a label re-applied on every launch") + } + }) + } +} diff --git a/agent/bob_key_relaunch_loop_test.go b/agent/bob_key_relaunch_loop_test.go new file mode 100644 index 0000000..207ce49 --- /dev/null +++ b/agent/bob_key_relaunch_loop_test.go @@ -0,0 +1,132 @@ +package agent + +import ( + "context" + "testing" + "time" + + "github.com/hivecommons/crux/config" + "github.com/hivecommons/crux/internal/testutil" +) + +// These tests cover the relaunch LOOP body of RelaunchBobAgentsAwaitingKey — +// the kill-stale-session, clear-backoff, and Start dispatch steps — which the +// selection/key-gate tests in bob_key_relaunch_test.go deliberately stop short +// of. The loop is where the #5958 backoff-clear and the one-failure-must-not- +// abort-the-fleet semantics live. + +// parkAgentForMissingKeyLocked puts a NewManager-built agent into exactly the +// state launchInTmux leaves a bob agent in when no API key is configured, +// including the start-failure record that recordStartFailureLocked writes. +func parkAgentForMissingKeyLocked(m *Manager, name, sentinelReason string) { + m.mu.Lock() + defer m.mu.Unlock() + agent := m.agents[name] + agent.State = StateFailed + agent.awaitingBobKey = true + agent.StartFailureClass = string(StartFailureCredentialMissing) + agent.StartFailureReason = sentinelReason + agent.StartFailureCount = 3 + // A live backoff window: without the clearStartFailureLocked call in the + // relaunch loop, this alone must not gate the retry (#5958), but the loop + // clears it so the record cannot go stale either way. + agent.StartBackoffUntil = time.Now().Add(time.Hour) +} + +// TestRelaunchBobAgentsAwaitingKeyRelaunchLoop drives one parked bob agent all +// the way through the loop body: the stale tmux session is killed, the start- +// failure backoff is cleared before Start, and a Start that does not error is +// reported as relaunched. +func TestRelaunchBobAgentsAwaitingKeyRelaunchLoop(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + t.Setenv("HIVE_WORK_DIR", t.TempDir()) + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: bobBackend}, + }, discardLogger(), ProjectContext{}) + m.SetBobAPIKeyResolver(func() string { return "resolved-test-key" }) + forceSharedUID(t, m, "scanner") + t.Cleanup(func() { cleanupAgent(t, m, "scanner") }) + + const sentinel = "sentinel: parked before key save" + parkAgentForMissingKeyLocked(m, "scanner", sentinel) + + m.mu.Lock() + agent := m.agents["scanner"] + // This test drives a real stale session through the Manager, so it needs + // the manager and fixture to target the same server. Keep that server + // private: other behavioural tests also use the conventional + // hive-scanner session name on the package-wide test server. + // Keep the suffix to one character: tmux socket paths have a length limit. + agent.tmuxSocket = defaultTmuxSocket + "b" + m.mu.Unlock() + + // A stale key-less session must exist so the loop's kill-session step has + // something real to tear down (the load-bearing half of the fix: the old + // pane's shell can never learn the key). + if err := testTmuxCommandOnSocket(agent.tmuxSocket, "new-session", "-d", "-s", agent.tmuxSession).Run(); err != nil { + testutil.SkipfUnlessRequired(t, "cannot create tmux session: %v", err) + } + + got := m.RelaunchBobAgentsAwaitingKey(context.Background()) + + // Start's park-and-return branches deliberately return nil (one agent must + // never abort the fleet), so with or without a bob binary on PATH the + // relaunch itself must be reported. + if len(got) != 1 || got[0] != "scanner" { + t.Fatalf("RelaunchBobAgentsAwaitingKey() = %v, want [scanner]", got) + } + + // The pre-save failure record must be gone. If the launch attempt failed + // again for a NEW reason (e.g. no bob binary in this environment) a fresh + // record may exist — but never the sentinel one, which would mean the + // backoff from the missing-key era survived the key save (#5958). + m.mu.RLock() + reason := agent.StartFailureReason + m.mu.RUnlock() + if reason == sentinel { + t.Errorf("StartFailureReason = %q still the pre-save record; clearStartFailureLocked was not applied before Start", reason) + } +} + +// TestRelaunchBobAgentsAwaitingKeyStartErrorContinues pins the fleet-safety +// rule: when Start errors for one candidate the loop logs, skips it, and still +// relaunches the rest — one agent's failure never aborts the fleet. +func TestRelaunchBobAgentsAwaitingKeyStartErrorContinues(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + t.Setenv("HIVE_WORK_DIR", t.TempDir()) + m := NewManager(map[string]config.AgentConfig{ + "stuck": {Backend: bobBackend}, + "scanner": {Backend: bobBackend}, + }, discardLogger(), ProjectContext{}) + m.SetBobAPIKeyResolver(func() string { return "resolved-test-key" }) + + parkAgentForMissingKeyLocked(m, "stuck", "parked") + parkAgentForMissingKeyLocked(m, "scanner", "parked") + + // A claimed launch guard makes Start return an error ("launch already in + // progress") without any tmux side effects — the cheapest deterministic + // Start failure available through the real code path. + m.mu.Lock() + m.agents["stuck"].launching = true + m.mu.Unlock() + + got := m.RelaunchBobAgentsAwaitingKey(context.Background()) + defer cleanupAgent(t, m, "scanner") + defer cleanupAgent(t, m, "stuck") + + if len(got) != 1 || got[0] != "scanner" { + t.Fatalf("RelaunchBobAgentsAwaitingKey() = %v, want [scanner] — the stuck agent's error must not abort the fleet", got) + } + + // The failed candidate must remain parked and eligible for a later save. + m.mu.RLock() + stillParked := m.agents["stuck"].awaitingBobKey + m.mu.RUnlock() + if !stillParked { + t.Error("stuck agent lost awaitingBobKey without a successful launch; a later key save would miss it") + } +} diff --git a/agent/bob_key_relaunch_test.go b/agent/bob_key_relaunch_test.go new file mode 100644 index 0000000..8389ecb --- /dev/null +++ b/agent/bob_key_relaunch_test.go @@ -0,0 +1,339 @@ +package agent + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/hivecommons/crux/config" +) + +// newRelaunchManager builds a bare Manager with the given agents registered. +// It deliberately does not touch tmux: these tests cover the selection rules +// and the key gate, which is where the risk lives. +func newRelaunchManager(agents map[string]*AgentProcess) *Manager { + m := &Manager{ + agents: make(map[string]*AgentProcess), + idToName: make(map[string]string), + logger: discardLogger(), + } + for name, a := range agents { + a.Name = name + m.agents[name] = a + } + return m +} + +// parkedBobAgent returns an agent in exactly the state launchInTmux leaves a +// bob agent in when no API key is configured. +func parkedBobAgent() *AgentProcess { + return &AgentProcess{ + Config: config.AgentConfig{Backend: bobBackend}, + State: StateFailed, + awaitingBobKey: true, + } +} + +// TestLaunchParksBobAgentAwaitingKey pins the marker half of the fix: the +// missing-key branch must set awaitingBobKey, because StateFailed alone is +// ambiguous (missing binary, copilot auth timeout, hung diagnostic). +func TestLaunchParksBobAgentAwaitingKey(t *testing.T) { + m := &Manager{logger: discardLogger()} + m.SetBobAPIKeyResolver(func() string { return "" }) + + agent := &AgentProcess{ + Name: "scanner", + Config: config.AgentConfig{Backend: bobBackend}, + } + // launchInTmux bails on the key check before any tmux work when the + // backend binary resolves; if bob is absent it bails even earlier, in + // which case the marker must stay false rather than be wrongly set. + if _, err := backendBinary(bobBackend); err != nil { + t.Skipf("bob binary not present in this environment: %v", err) + } + if err := m.launchInTmux(context.Background(), agent); err != nil { + t.Fatalf("launchInTmux returned error, want nil so one agent never aborts the fleet: %v", err) + } + if agent.State != StateFailed { + t.Errorf("State = %q, want %q", agent.State, StateFailed) + } + if !agent.awaitingBobKey { + t.Error("awaitingBobKey = false, want true — the key-save relaunch cannot find this agent without the marker") + } +} + +// TestBobAgentsAwaitingKeySelection is the core of the change: exactly the +// agents parked for a missing key are eligible, and nothing else ever is. +func TestBobAgentsAwaitingKeySelection(t *testing.T) { + tests := []struct { + name string + agent *AgentProcess + want bool + }{ + { + name: "bob parked for missing key is selected", + agent: parkedBobAgent(), + want: true, + }, + { + name: "healthy running bob agent is not disturbed", + agent: &AgentProcess{ + Config: config.AgentConfig{Backend: bobBackend}, + State: StateRunning, + }, + want: false, + }, + { + name: "running bob agent with a stale marker is still not disturbed", + agent: &AgentProcess{ + Config: config.AgentConfig{Backend: bobBackend}, + State: StateRunning, + awaitingBobKey: true, + }, + want: false, + }, + { + name: "operator-paused bob agent is not resumed by a key save", + agent: &AgentProcess{ + Config: config.AgentConfig{Backend: bobBackend, Paused: true}, + State: StatePaused, + Paused: true, + PausedTrigger: "dashboard-api", + awaitingBobKey: true, + }, + want: false, + }, + { + name: "claude agent is never touched", + agent: &AgentProcess{ + Config: config.AgentConfig{Backend: "claude"}, + State: StateFailed, + }, + want: false, + }, + { + name: "failed claude agent with a spurious marker is rejected by the backend check", + agent: &AgentProcess{ + Config: config.AgentConfig{Backend: "claude"}, + State: StateFailed, + awaitingBobKey: true, + }, + want: false, + }, + { + name: "bob agent failed for another reason is not selected", + agent: &AgentProcess{ + Config: config.AgentConfig{Backend: bobBackend}, + State: StateFailed, + LastError: "backend binary not found", + }, + want: false, + }, + { + name: "agent overridden away from bob after parking is rejected", + agent: &AgentProcess{ + Config: config.AgentConfig{Backend: bobBackend}, + BackendOverride: "claude", + State: StateFailed, + awaitingBobKey: true, + }, + want: false, + }, + { + name: "agent overridden TO bob and parked is selected", + agent: &AgentProcess{ + Config: config.AgentConfig{Backend: "claude"}, + BackendOverride: bobBackend, + State: StateFailed, + awaitingBobKey: true, + }, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newRelaunchManager(map[string]*AgentProcess{"subject": tc.agent}) + got := m.bobAgentsAwaitingKey() + if tc.want && len(got) != 1 { + t.Fatalf("bobAgentsAwaitingKey() = %v, want [subject]", got) + } + if !tc.want && len(got) != 0 { + t.Fatalf("bobAgentsAwaitingKey() = %v, want no candidates", got) + } + }) + } +} + +// TestRelaunchBobAgentsAwaitingKeyRequiresResolvedKey covers the clear path and +// the not-yet-configured path: with no key, relaunching would just re-park +// every agent on the same branch, so it must be a no-op. +func TestRelaunchBobAgentsAwaitingKeyRequiresResolvedKey(t *testing.T) { + tests := []struct { + name string + inject bool + key string + wantNil bool + }{ + {name: "no resolver injected", inject: false, wantNil: true}, + {name: "key cleared", inject: true, key: "", wantNil: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newRelaunchManager(map[string]*AgentProcess{"scanner": parkedBobAgent()}) + if tc.inject { + key := tc.key + m.SetBobAPIKeyResolver(func() string { return key }) + } + if got := m.RelaunchBobAgentsAwaitingKey(context.Background()); len(got) != 0 { + t.Errorf("RelaunchBobAgentsAwaitingKey() = %v, want none — clearing a key must never launch anything", got) + } + // The agent must remain parked and eligible for a later real save. + if !m.agents["scanner"].awaitingBobKey { + t.Error("awaitingBobKey was cleared without a launch; a later key save would miss this agent") + } + }) + } +} + +// TestRelaunchIsIdempotentOnDoubleSave pins that saving a key twice cannot +// double-launch. The first pass clears the marker (launchInTmux does this on +// every launch attempt), so the second pass finds no candidates. +func TestRelaunchIsIdempotentOnDoubleSave(t *testing.T) { + agent := parkedBobAgent() + m := newRelaunchManager(map[string]*AgentProcess{"scanner": agent}) + m.SetBobAPIKeyResolver(func() string { return "bob-key-abc" }) + + if got := m.bobAgentsAwaitingKey(); len(got) != 1 { + t.Fatalf("first save: got %v candidates, want 1", got) + } + + // Simulate what launchInTmux does at the top of every launch attempt. + agent.awaitingBobKey = false + agent.State = StateRunning + + if got := m.bobAgentsAwaitingKey(); len(got) != 0 { + t.Errorf("second save: got %v, want none — a double save must not double-launch", got) + } +} + +// TestRelaunchNoDeadlockUnderConcurrency exercises the lock discipline that +// incident #1980→#1988 turned into a hard rule: m.mu is a NON-reentrant +// RWMutex, and Start takes m.mu.Lock() itself, so candidate collection must +// release the read lock before any launch. This test hammers the selection +// path against concurrent writers holding the write lock; a re-entrant +// acquisition anywhere in the path hangs here instead of in production. +func TestRelaunchNoDeadlockUnderConcurrency(t *testing.T) { + const ( + // concurrentWorkers is the number of goroutines per role (readers and + // writers). Enough to interleave lock acquisitions reliably without + // making the test slow. + concurrentWorkers = 8 + // iterationsPerWorker is how many times each goroutine takes the lock. + iterationsPerWorker = 50 + // deadlockTimeout bounds the run. The contended work below finishes in + // milliseconds, so anything approaching this means the lock is held, + // not that the machine is slow. + deadlockTimeout = 30 * time.Second + ) + + agents := map[string]*AgentProcess{ + "parked": parkedBobAgent(), + "running": {Config: config.AgentConfig{Backend: bobBackend}, State: StateRunning}, + "claude": {Config: config.AgentConfig{Backend: "claude"}, State: StateFailed}, + } + m := newRelaunchManager(agents) + m.SetBobAPIKeyResolver(func() string { return "bob-key-abc" }) + + var wg sync.WaitGroup + for i := 0; i < concurrentWorkers; i++ { + wg.Add(2) + // Reader: the selection path under test. + go func() { + defer wg.Done() + for j := 0; j < iterationsPerWorker; j++ { + m.bobAgentsAwaitingKey() + } + }() + // Writer: contends for the write lock the way Start/Pause do. + go func() { + defer wg.Done() + for j := 0; j < iterationsPerWorker; j++ { + m.mu.Lock() + m.agents["running"].RestartCount++ + m.mu.Unlock() + } + }() + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(deadlockTimeout): + t.Fatal("deadlock: selection path did not complete under lock contention") + } +} + +// TestKillSessionForRelaunchClearsRunningState pins the reason the relaunch +// recreates the tmux session instead of just re-typing the launch command. +// +// BOBSHELL_API_KEY is Secret, so buildEnvPrefix omits it from the typed command +// line and tmux set-environment is its only delivery path — and that is +// inherited only by shells created afterwards. The pane's bash predates the +// key, and reapAgentCLI kills the bob CLI but not that bash, so relaunching +// into the same session hands bob a shell with no key (verified live on +// hosted-available-vllmd-01: key present in the tmux session env, absent from +// every bob /proc//environ). +// +// The state reset matters too: Start refuses to launch an agent it thinks is +// running, so leaving StateRunning after killing its session would make the +// relaunch a silent no-op. +func TestKillSessionForRelaunchClearsRunningState(t *testing.T) { + tests := []struct { + name string + state ProcessState + want ProcessState + }{ + {name: "running is reset so Start will relaunch", state: StateRunning, want: StateStopped}, + {name: "failed is left alone", state: StateFailed, want: StateFailed}, + {name: "stopped is left alone", state: StateStopped, want: StateStopped}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + agent := parkedBobAgent() + agent.State = tc.state + // No tmuxSession set: tmuxCmd runs a kill-session that simply + // fails, which the helper ignores by design ("already gone" is a + // satisfied precondition, not an error). + m := newRelaunchManager(map[string]*AgentProcess{"scanner": agent}) + if err := m.killSessionForRelaunch("scanner"); err != nil { + t.Fatalf("killSessionForRelaunch() error = %v, want nil", err) + } + if agent.State != tc.want { + t.Errorf("State = %q, want %q", agent.State, tc.want) + } + }) + } +} + +// TestKillSessionForRelaunchUnknownAgent ensures a vanished agent surfaces an +// error rather than panicking the key-save request. +func TestKillSessionForRelaunchUnknownAgent(t *testing.T) { + m := newRelaunchManager(nil) + if err := m.killSessionForRelaunch("ghost"); err == nil { + t.Error("killSessionForRelaunch() on an unknown agent = nil, want an error") + } +} + +// TestRelaunchHandlesEmptyFleet guards the nil/empty cases the dashboard can +// hit before any agent is registered. +func TestRelaunchHandlesEmptyFleet(t *testing.T) { + m := newRelaunchManager(nil) + m.SetBobAPIKeyResolver(func() string { return "bob-key-abc" }) + if got := m.RelaunchBobAgentsAwaitingKey(context.Background()); len(got) != 0 { + t.Errorf("RelaunchBobAgentsAwaitingKey() on empty fleet = %v, want none", got) + } +} diff --git a/agent/bob_launch_shell_test.go b/agent/bob_launch_shell_test.go new file mode 100644 index 0000000..e3bef53 --- /dev/null +++ b/agent/bob_launch_shell_test.go @@ -0,0 +1,49 @@ +package agent + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestBobLaunchCmdRunsBehindEnvPrefix executes the real tmux launch shape — +// KEY='value' assignments followed by bobLaunchCmd — through bash against a +// fake bob. Before the sh -c wrap, `A='1' case ... esac` was a bash syntax +// error and bob never launched on any hive. +func TestBobLaunchCmdRunsBehindEnvPrefix(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash not available") + } + + cases := []struct { + version string + wantArgs string + }{ + {"2.0.4", "chat --accept-license --auto-approve --trust"}, + {"1.0.6", "--accept-license --auth-method api-key --approval-mode yolo --trust"}, + } + for _, tc := range cases { + t.Run(tc.version, func(t *testing.T) { + fake := filepath.Join(t.TempDir(), "bob") + script := "#!/bin/sh\n" + + "if [ \"$1\" = --version ]; then echo " + tc.version + "; echo commit: x; exit 0; fi\n" + + "echo \"ARGS=$* HIVE_AGENT=$HIVE_AGENT\"\n" + if err := os.WriteFile(fake, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + line := shellEnvVar("HIVE_AGENT", "quality") + " " + bobLaunchCmd(fake) + out, err := exec.Command(bash, "-c", line).CombinedOutput() + if err != nil { + t.Fatalf("launch line failed: %v\nline: %s\noutput: %s", err, line, out) + } + want := "ARGS=" + tc.wantArgs + " HIVE_AGENT=quality" + if got := strings.TrimSpace(string(out)); got != want { + t.Errorf("output = %q, want %q", got, want) + } + }) + } +} diff --git a/agent/bob_settings.go b/agent/bob_settings.go new file mode 100644 index 0000000..5a00495 --- /dev/null +++ b/agent/bob_settings.go @@ -0,0 +1,441 @@ +package agent + +import ( + "encoding/json" + "io/fs" + "os" + "path/filepath" + + "github.com/hivecommons/crux/config" +) + +// Unix permission bits for the agent-UID readability probe. Named rather than +// inlined so the intent of each mask is explicit at the call site. +const ( + // bobKeyGroupReadBit is `g+r` — the bit that lets an agent UID (2001+, in + // group node) read a dev:node-owned key file. + bobKeyGroupReadBit fs.FileMode = 0o040 + // bobKeyGroupExecBit is `g+x` — on a DIRECTORY this is traverse + // permission, required to open a file inside it by name. + bobKeyGroupExecBit fs.FileMode = 0o010 + // bobKeyOtherReadBit is `o+r`. Also sufficient for the agent to read, but + // it means the secret is world-readable, which is worth flagging. + bobKeyOtherReadBit fs.FileMode = 0o004 + // bobStateGroupWriteBit is `g+w` — the bit that lets an agent UID create + // or replace files inside a dev:node-owned bob state directory. + bobStateGroupWriteBit fs.FileMode = 0o020 +) + +// verifyBobKeyReadable checks that the resolved bob key file is readable BY +// THE AGENT UID, and logs an actionable error when it is not. +// +// This exists because the obvious check is a false positive. ResolveAPIKey +// runs in the hive process as dev, which owns the key file — so it succeeds, +// the Governor Bob tab renders "✓ Key set", and everything looks configured. +// But bobshell reads BOBSHELL_API_KEY while running as the agent UID, which on +// a dev:node 0600 file gets EACCES. The key resolves empty, bob silently falls +// back to the W3ID browser SSO flow that cannot complete in a pod, and the +// agent sits at the auth prompt with no error anywhere. That silent +// disagreement between "hive can read it" and "the agent can read it" is the +// whole bug, so it gets an explicit probe. +// +// Root agents (UID 0) and unset UIDs are skipped — they can read anything. +// Reports whether the key looks readable; callers log-and-continue rather than +// refusing to launch, since the Secret-mounted path may still work. +// Logs paths, modes and UIDs only — never the key value. +func (m *Manager) verifyBobKeyReadable(agentName, keyPath string, uid int) bool { + if uid <= 0 || keyPath == "" { + return true + } + + info, err := os.Stat(keyPath) + if err != nil { + m.logger.Warn("bob key file not statable; bob will fall back to SSO and hang at the auth prompt", + "agent", agentName, "path", keyPath, "uid", uid, "error", err) + return false + } + mode := info.Mode().Perm() + + // The directory must be group-traversable or the file cannot be opened at + // all, regardless of its own mode. + dir := filepath.Dir(keyPath) + if dirInfo, derr := os.Stat(dir); derr == nil { + if dirMode := dirInfo.Mode().Perm(); dirMode&bobKeyGroupExecBit == 0 { + m.logger.Error("bob key directory is not traversable by the agent UID; bob cannot read the key and will hang at the SSO prompt", + "agent", agentName, "dir", dir, + "dir_mode", fs.FileMode(dirMode).String(), "uid", uid, + "remedy", "chmod 710 "+dir+" (group execute lets the agent open the key by name without listing the dir)") + return false + } + } + + if mode&bobKeyGroupReadBit == 0 && mode&bobKeyOtherReadBit == 0 { + m.logger.Error("bob key file is not readable by the agent UID; bob cannot read the key and will hang at the SSO prompt", + "agent", agentName, "path", keyPath, + "mode", fs.FileMode(mode).String(), "uid", uid, + "remedy", "chmod 440 "+keyPath+" and ensure it is group-owned by node") + return false + } + if mode&bobKeyOtherReadBit != 0 { + m.logger.Warn("bob key file is world-readable; group-read (440) is sufficient", + "agent", agentName, "path", keyPath, "mode", fs.FileMode(mode).String()) + } + return true +} + +// bobSharedHome is the HOME every non-inference agent runs with (see +// agentEnvPairs). bob resolves its settings file from os.homedir(), so all bob +// agents on a hive share ONE /data/home/.bob/settings.json. +const bobSharedHome = "/data/home" + +// bobSettingsPath returns the shared bob settings file for a given HOME. +func bobSettingsPath(home string) string { + return filepath.Join(home, config.BobSettingsRelPath) +} + +// bobV2SettingsPath returns bobshell 2.x's shared global settings file. +func bobV2SettingsPath(home string) string { + return filepath.Join(home, config.BobV2SettingsRelPath) +} + +// nearestExistingDir walks up from dir and returns the first ancestor that +// exists, or "" if none does. Used to locate the directory whose permissions +// actually govern a recursive mkdir of a not-yet-existing path. +func nearestExistingDir(dir string) string { + for { + if _, err := os.Stat(dir); err == nil { + return dir + } + parent := filepath.Dir(dir) + // filepath.Dir is its own fixed point at the root ("/" or "."), which + // is the loop's only termination condition. + if parent == dir { + return "" + } + dir = parent + } +} + +// verifyBobStateDirsWritable probes, AS THE AGENT UID, every directory bob +// writes to during startup, and logs an actionable error for each one that is +// not writable. +// +// This exists because bob reports these failures only inside its own TUI — +// "There was an error saving your latest settings changes." and "Failed to +// initialize logger:" — which nobody sees unless they are attached to that +// pane. Nothing reaches the hive log, so a genuinely unwritable directory is +// invisible to an operator reading `kubectl logs` and gets misdiagnosed as a +// read-only filesystem. The same false-positive trap as verifyBobKeyReadable +// applies: the hive process runs as dev and OWNS these directories, so any +// check it performs on its own behalf succeeds while the agent UID still gets +// EACCES. The probe therefore tests the agent's group access explicitly rather +// than calling os.Access as dev. +// +// Advisory only — it never blocks a launch. bob degrades rather than dying +// when these writes fail (ephemeral installation ID, no chat recording), so a +// probe failure is a reason to log loudly, not to refuse to start the agent. +// Logs paths, modes and UIDs only — never any file contents. +// +// Returns the directories found unwritable, so callers and tests can assert on +// the outcome rather than scraping log output. A nil result means every +// existing state dir is writable by the agent UID. +func (m *Manager) verifyBobStateDirsWritable(agentName, home, workDir string, uid int) []string { + if uid <= 0 { + return nil + } + var unwritable []string + + // The directories bob creates or writes during startup: + // - $HOME/.bob settings.json, trustedFolders.json, + // installation_id, tmp/ (chat recordings) + // - /.bob per-agent .bob-errors/ logger target, which is + // what "Failed to initialize logger:" refers to + probeDirs := []string{ + filepath.Dir(bobSettingsPath(home)), + filepath.Dir(bobV2SettingsPath(home)), + filepath.Join(workDir, config.BobStateDirName), + } + + for _, dir := range probeDirs { + info, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + // A missing dir is not itself a failure — bob creates these + // with mkdirSync({recursive:true}) on first use. But that + // mkdir runs as the AGENT UID, so it succeeds only if the + // PARENT is group-writable. Probing the parent here is the + // whole point on a fresh hive: this is precisely the + // first-run EACCES in #2284, and the original probe returned + // "all clear" for it because it skipped missing dirs + // entirely. Check the nearest existing ancestor instead. + if parent := nearestExistingDir(filepath.Dir(dir)); parent != "" { + if pInfo, pErr := os.Stat(parent); pErr == nil { + pMode := pInfo.Mode().Perm() + if pMode&bobStateGroupWriteBit == 0 || pMode&bobKeyGroupExecBit == 0 { + m.logger.Error("bob state dir does not exist and its parent is not writable by the agent UID; bob's first-run mkdir will fail with EACCES", + "agent", agentName, "dir", dir, "parent", parent, + "parent_mode", fs.FileMode(pMode).String(), "uid", uid, + "remedy", "chmod 2775 "+parent+" and ensure it is group-owned by node") + unwritable = append(unwritable, dir) + } + } + } + continue + } + m.logger.Warn("bob state dir not statable; bob may fail to persist settings or initialize its logger", + "agent", agentName, "dir", dir, "uid", uid, "error", err) + continue + } + if !info.IsDir() { + m.logger.Warn("bob state path exists but is not a directory", + "agent", agentName, "path", dir, "uid", uid) + continue + } + + // The agent UID reaches these dev-owned dirs through group `node`, so + // group write+execute is what actually decides whether bob can create + // or replace a file inside. Write alone is not enough — without + // execute the path cannot be traversed to open the file by name. + mode := info.Mode().Perm() + if mode&bobStateGroupWriteBit == 0 || mode&bobKeyGroupExecBit == 0 { + m.logger.Error("bob state dir is not writable by the agent UID; bob will report \"error saving your latest settings changes\" and \"Failed to initialize logger\"", + "agent", agentName, "dir", dir, + "mode", fs.FileMode(mode).String(), "uid", uid, + "remedy", "chmod 770 "+dir+" and ensure it is group-owned by node") + unwritable = append(unwritable, dir) + } + } + return unwritable +} + +// ensureBobAuthSettings makes hive the owner of bob's auth-selection settings. +// +// Why this is needed at all: BOBSHELL_DEFAULT_AUTH_TYPE is only a fallback +// DEFAULT. Per bundle/bob.js, a persisted security.auth.selectedType is +// consulted FIRST and wins over the env var, so a single agent that once +// picked "Log in with IBM" at the prompt writes selectedType:"sso" into the +// SHARED file and parks every bob agent on the hive at the SSO/API-key prompt. +// +// What it writes: +// - selectedType = "api-key" — the choice itself. +// - enforcedType = "api-key" — strictly stronger. The bundle filters the +// option list down to enforcedType +// (`enforcedType && (a=a.filter(p=>p.value===enforcedType))`), so SSO stops +// being selectable, and eEr() turns any other active type into an error. +// Together these mean a stray write cannot re-break the fleet. +// +// It is called on EVERY bob launch, so an out-of-band write self-heals on the +// next launch rather than persisting until someone notices. +// +// It also disables bob self-update in every known settings shape. Hive images +// pin bob explicitly, and agents run as non-root UIDs, so a runtime +// `npm install -g` into root-owned /usr/local fails with EACCES and paints a +// red error in every pane. bobshell 2.0.4 reads bobShell.autoUpdate from +// ~/.bob/settings/settings.json; older bob/gemini-lineage builds use +// disableAutoUpdate / disableUpdateNag keys in ~/.bob/settings.json. +// +// The merge is deliberately key-scoped: the file also holds instanceId, +// teamId, licenseConsent, isNotFirstTime, prev_version and ibm_secrets, and +// clobbering licenseConsent alone would make bob hard-error on startup. Unknown +// keys are decoded into a generic map and re-encoded untouched. +// +// bobshell 2.x removed the auth picker and defaults to the "harness" provider +// (BOB_API_KEY), but the persisted provider still outranks the default. Hive +// writes that v2 setting as well so its api-key choice stays authoritative over +// a shared HOME that a previous interactive bob run may have mutated. +// +// Errors are logged and swallowed: a settings file we cannot write is not a +// reason to abort a launch that may still succeed via the env var. +// The API key is never read or written here — only the auth TYPE/provider. +func (m *Manager) ensureBobAuthSettings(agentName, home string) { + m.ensureBobV1AuthSettings(agentName, home) + m.ensureBobV2AuthSettings(agentName, home) +} + +func (m *Manager) ensureBobV1AuthSettings(agentName, home string) { + path := bobSettingsPath(home) + + if err := os.MkdirAll(filepath.Dir(path), config.BobSettingsDirMode); err != nil { + m.logger.Warn("bob settings: mkdir failed", + "agent", agentName, "path", filepath.Dir(path), "error", err) + return + } + + settings := map[string]any{} + data, err := os.ReadFile(path) + switch { + case err == nil: + // A corrupt/truncated file must not wedge every bob agent forever. + // Start from an empty object instead: the keys we would have preserved + // are already unreadable, and bob re-derives them (licenseConsent comes + // back via --accept-license on the launch command). + if uerr := json.Unmarshal(data, &settings); uerr != nil { + m.logger.Warn("bob settings: unparseable, rewriting auth block from empty", + "agent", agentName, "path", path, "error", uerr) + settings = map[string]any{} + } + case !os.IsNotExist(err): + m.logger.Warn("bob settings: read failed", + "agent", agentName, "path", path, "error", err) + return + } + + changed := setBobAuthBlock(settings) + changed = setBobLegacyUpdateBlock(settings) || changed + if !changed { + return + } + + out, err := json.MarshalIndent(settings, "", " ") + if err != nil { + m.logger.Warn("bob settings: marshal failed", "agent", agentName, "error", err) + return + } + if err := os.WriteFile(path, out, config.BobSettingsFileMode); err != nil { + m.logger.Warn("bob settings: write failed", + "agent", agentName, "path", path, "error", err) + return + } + // Re-chmod explicitly: WriteFile's mode only applies at CREATE time, so an + // existing file created with a tighter umask would stay non-group-writable + // and the next agent UID could not update it. + if err := os.Chmod(path, config.BobSettingsFileMode); err != nil { + m.logger.Warn("bob settings: chmod failed", + "agent", agentName, "path", path, "error", err) + } + // Log the TYPE and PATH only — never the key. + m.logger.Info("bob settings: asserted api-key auth", + "agent", agentName, "path", path, + "selected_type", config.BobAuthTypeAPIKey, + "enforced_type", config.BobAuthTypeAPIKey) +} + +func (m *Manager) ensureBobV2AuthSettings(agentName, home string) { + path := bobV2SettingsPath(home) + + if err := os.MkdirAll(filepath.Dir(path), config.BobSettingsDirMode); err != nil { + m.logger.Warn("bob v2 settings: mkdir failed", + "agent", agentName, "path", filepath.Dir(path), "error", err) + return + } + + settings := map[string]any{} + data, err := os.ReadFile(path) + switch { + case err == nil: + if uerr := json.Unmarshal(data, &settings); uerr != nil { + m.logger.Warn("bob v2 settings: unparseable, rewriting provider from empty", + "agent", agentName, "path", path, "error", uerr) + settings = map[string]any{} + } + case !os.IsNotExist(err): + m.logger.Warn("bob v2 settings: read failed", + "agent", agentName, "path", path, "error", err) + return + } + + changed := false + if cur, _ := settings[config.BobV2ProviderKey].(string); cur != config.BobV2ProviderHarness { + settings[config.BobV2ProviderKey] = config.BobV2ProviderHarness + changed = true + } + changed = setBobV2AutoUpdateDisabled(settings) || changed + if !changed { + return + } + + out, err := json.MarshalIndent(settings, "", " ") + if err != nil { + m.logger.Warn("bob v2 settings: marshal failed", "agent", agentName, "error", err) + return + } + if err := os.WriteFile(path, out, config.BobSettingsFileMode); err != nil { + m.logger.Warn("bob v2 settings: write failed", + "agent", agentName, "path", path, "error", err) + return + } + if err := os.Chmod(path, config.BobSettingsFileMode); err != nil { + m.logger.Warn("bob v2 settings: chmod failed", + "agent", agentName, "path", path, "error", err) + } + m.logger.Info("bob v2 settings: asserted harness provider", + "agent", agentName, "path", path, "provider", config.BobV2ProviderHarness) +} + +// setBobAuthBlock merges the hive-owned auth values into settings in place, +// creating the intermediate `security` / `security.auth` objects as needed and +// preserving every sibling key. It reports whether anything actually changed, +// so a already-correct file is not rewritten on every launch (which would +// churn mtime and race concurrent launches for no benefit). +// +// Intermediate values of the wrong JSON type (e.g. `"security": "yes"`) are +// replaced rather than merged — there is no sane merge, and bob itself would +// fail to read them. +func setBobAuthBlock(settings map[string]any) bool { + security, ok := settings[config.BobSettingsSecurityKey].(map[string]any) + if !ok { + security = map[string]any{} + settings[config.BobSettingsSecurityKey] = security + } + auth, ok := security[config.BobSettingsAuthKey].(map[string]any) + if !ok { + auth = map[string]any{} + security[config.BobSettingsAuthKey] = auth + } + + changed := false + for _, key := range []string{ + config.BobSettingsSelectedTypeKey, + config.BobSettingsEnforcedTypeKey, + } { + if cur, _ := auth[key].(string); cur != config.BobAuthTypeAPIKey { + auth[key] = config.BobAuthTypeAPIKey + changed = true + } + } + return changed +} + +func setBobLegacyUpdateBlock(settings map[string]any) bool { + changed := false + for _, key := range []string{ + config.BobLegacyDisableAutoUpdateKey, + config.BobLegacyDisableUpdateNagKey, + } { + if cur, _ := settings[key].(bool); !cur { + settings[key] = true + changed = true + } + } + + general, ok := settings[config.BobLegacyGeneralKey].(map[string]any) + if !ok { + general = map[string]any{} + settings[config.BobLegacyGeneralKey] = general + changed = true + } + for _, key := range []string{ + config.BobLegacyDisableAutoUpdateKey, + config.BobLegacyDisableUpdateNagKey, + } { + if cur, _ := general[key].(bool); !cur { + general[key] = true + changed = true + } + } + return changed +} + +func setBobV2AutoUpdateDisabled(settings map[string]any) bool { + shell, ok := settings[config.BobV2ShellKey].(map[string]any) + changed := !ok + if !ok { + shell = map[string]any{} + settings[config.BobV2ShellKey] = shell + } + if cur, exists := shell[config.BobV2AutoUpdateKey].(bool); exists && !cur { + return changed + } + shell[config.BobV2AutoUpdateKey] = false + return true +} diff --git a/agent/bob_settings_test.go b/agent/bob_settings_test.go new file mode 100644 index 0000000..f895425 --- /dev/null +++ b/agent/bob_settings_test.go @@ -0,0 +1,676 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/hivecommons/crux/config" +) + +// readBobSettings decodes the settings file written under home. +func readBobSettings(t *testing.T, home string) map[string]any { + t.Helper() + data, err := os.ReadFile(bobSettingsPath(home)) + if err != nil { + t.Fatalf("read settings: %v", err) + } + var got map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal settings: %v", err) + } + return got +} + +func readBobV2Settings(t *testing.T, home string) map[string]any { + t.Helper() + data, err := os.ReadFile(bobV2SettingsPath(home)) + if err != nil { + t.Fatalf("read v2 settings: %v", err) + } + var got map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal v2 settings: %v", err) + } + return got +} + +// authBlock digs out security.auth, failing if the shape is wrong. +func authBlock(t *testing.T, settings map[string]any) map[string]any { + t.Helper() + security, ok := settings[config.BobSettingsSecurityKey].(map[string]any) + if !ok { + t.Fatalf("settings[%q] is not an object: %#v", config.BobSettingsSecurityKey, settings) + } + auth, ok := security[config.BobSettingsAuthKey].(map[string]any) + if !ok { + t.Fatalf("security[%q] is not an object: %#v", config.BobSettingsAuthKey, security) + } + return auth +} + +// assertAPIKeyAuth pins both hive-owned values. +func assertAPIKeyAuth(t *testing.T, auth map[string]any) { + t.Helper() + for _, key := range []string{ + config.BobSettingsSelectedTypeKey, + config.BobSettingsEnforcedTypeKey, + } { + if got, _ := auth[key].(string); got != config.BobAuthTypeAPIKey { + t.Errorf("auth[%q] = %q, want %q", key, got, config.BobAuthTypeAPIKey) + } + } +} + +func assertBobLegacyUpdateDisabled(t *testing.T, settings map[string]any) { + t.Helper() + for _, key := range []string{ + config.BobLegacyDisableAutoUpdateKey, + config.BobLegacyDisableUpdateNagKey, + } { + if got, _ := settings[key].(bool); !got { + t.Errorf("settings[%q] = %#v, want true", key, settings[key]) + } + } + general, ok := settings[config.BobLegacyGeneralKey].(map[string]any) + if !ok { + t.Fatalf("settings[%q] is not an object: %#v", config.BobLegacyGeneralKey, settings[config.BobLegacyGeneralKey]) + } + for _, key := range []string{ + config.BobLegacyDisableAutoUpdateKey, + config.BobLegacyDisableUpdateNagKey, + } { + if got, _ := general[key].(bool); !got { + t.Errorf("general[%q] = %#v, want true", key, general[key]) + } + } +} + +func assertBobV2AutoUpdateDisabled(t *testing.T, settings map[string]any) { + t.Helper() + shell, ok := settings[config.BobV2ShellKey].(map[string]any) + if !ok { + t.Fatalf("v2 settings[%q] is not an object: %#v", config.BobV2ShellKey, settings[config.BobV2ShellKey]) + } + if got, _ := shell[config.BobV2AutoUpdateKey].(bool); got { + t.Errorf("bobShell.autoUpdate = true, want false") + } else if _, ok := shell[config.BobV2AutoUpdateKey]; !ok { + t.Errorf("bobShell.autoUpdate missing, want false") + } +} + +// writeBobSettings seeds a settings file with the given raw JSON. +func writeBobSettings(t *testing.T, home, raw string) { + t.Helper() + path := bobSettingsPath(home) + if err := os.MkdirAll(filepath.Dir(path), config.BobSettingsDirMode); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(raw), config.BobSettingsFileMode); err != nil { + t.Fatalf("seed settings: %v", err) + } +} + +// TestEnsureBobAuthSettingsCreatesFile covers the fresh-PVC case: no .bob dir +// at all. bob must still find api-key auth already selected and enforced. +func TestEnsureBobAuthSettingsCreatesFile(t *testing.T) { + home := t.TempDir() + m := &Manager{logger: discardLogger()} + + m.ensureBobAuthSettings("tester", home) + + gotV1 := readBobSettings(t, home) + assertAPIKeyAuth(t, authBlock(t, gotV1)) + assertBobLegacyUpdateDisabled(t, gotV1) + gotV2 := readBobV2Settings(t, home) + if got := gotV2[config.BobV2ProviderKey]; got != config.BobV2ProviderHarness { + t.Errorf("v2 provider = %#v, want %q", got, config.BobV2ProviderHarness) + } + assertBobV2AutoUpdateDisabled(t, gotV2) +} + +// TestEnsureBobAuthSettingsOverwritesSSO is the core regression guard: this is +// the exact on-disk state observed on the live spoke, where one agent had +// picked SSO at the prompt and thereby parked every bob agent on the hive. +// Unrelated keys must survive untouched — clobbering licenseConsent in +// particular would make bob hard-error before it does any work. +func TestEnsureBobAuthSettingsOverwritesSSO(t *testing.T) { + home := t.TempDir() + writeBobSettings(t, home, `{ + "instanceId": "inst-123", + "teamId": "team-456", + "licenseConsent": true, + "isNotFirstTime": true, + "security": {"auth": {"selectedType": "sso"}}, + "ibm_secrets": {}, + "prev_version": "1.0.6" + }`) + + m := &Manager{logger: discardLogger()} + m.ensureBobAuthSettings("tester", home) + + got := readBobSettings(t, home) + assertAPIKeyAuth(t, authBlock(t, got)) + assertBobLegacyUpdateDisabled(t, got) + + for key, want := range map[string]any{ + "instanceId": "inst-123", + "teamId": "team-456", + "licenseConsent": true, + "isNotFirstTime": true, + "prev_version": "1.0.6", + } { + if got[key] != want { + t.Errorf("preserved key %q = %#v, want %#v", key, got[key], want) + } + } + if _, ok := got["ibm_secrets"]; !ok { + t.Error("ibm_secrets key was dropped; unrelated keys must be preserved") + } +} + +func TestEnsureBobAuthSettingsOverwritesV2ProviderAndDisablesAutoUpdate(t *testing.T) { + home := t.TempDir() + path := bobV2SettingsPath(home) + if err := os.MkdirAll(filepath.Dir(path), config.BobSettingsDirMode); err != nil { + t.Fatalf("mkdir v2 settings: %v", err) + } + if err := os.WriteFile(path, []byte(`{"provider":"openrouter","bobShell":{"autoUpdate":true,"lastRunVersion":"2.0.4"},"session":{"maxTurns":7}}`), config.BobSettingsFileMode); err != nil { + t.Fatalf("seed v2 settings: %v", err) + } + + m := &Manager{logger: discardLogger()} + m.ensureBobAuthSettings("tester", home) + + got := readBobV2Settings(t, home) + if got[config.BobV2ProviderKey] != config.BobV2ProviderHarness { + t.Errorf("v2 provider = %#v, want %q", got[config.BobV2ProviderKey], config.BobV2ProviderHarness) + } + assertBobV2AutoUpdateDisabled(t, got) + if shell, _ := got[config.BobV2ShellKey].(map[string]any); shell["lastRunVersion"] != "2.0.4" { + t.Errorf("v2 bobShell sibling settings were not preserved: %#v", got[config.BobV2ShellKey]) + } + if session, _ := got["session"].(map[string]any); session["maxTurns"] != float64(7) { + t.Errorf("v2 session settings were not preserved: %#v", got["session"]) + } +} + +func TestEnsureBobAuthSettingsPreservesLegacyUpdateSiblings(t *testing.T) { + home := t.TempDir() + writeBobSettings(t, home, `{ + "general": {"theme": "dark", "disableAutoUpdate": false}, + "disableUpdateNag": false, + "security": {"auth": {"selectedType": "api-key", "enforcedType": "api-key"}} + }`) + + m := &Manager{logger: discardLogger()} + m.ensureBobAuthSettings("tester", home) + + got := readBobSettings(t, home) + assertBobLegacyUpdateDisabled(t, got) + general := got[config.BobLegacyGeneralKey].(map[string]any) + if general["theme"] != "dark" { + t.Errorf("general.theme = %#v, want dark", general["theme"]) + } +} + +// TestEnsureBobAuthSettingsPreservesSiblingAuthKeys checks that the merge is +// scoped to the two keys hive owns, not to the whole auth object. +func TestEnsureBobAuthSettingsPreservesSiblingAuthKeys(t *testing.T) { + home := t.TempDir() + writeBobSettings(t, home, `{"security":{"auth":{"selectedType":"sso","useExternal":true},"folderTrust":{"x":1}}}`) + + m := &Manager{logger: discardLogger()} + m.ensureBobAuthSettings("tester", home) + + got := readBobSettings(t, home) + auth := authBlock(t, got) + assertAPIKeyAuth(t, auth) + if auth["useExternal"] != true { + t.Errorf("security.auth.useExternal = %#v, want true", auth["useExternal"]) + } + security := got[config.BobSettingsSecurityKey].(map[string]any) + if _, ok := security["folderTrust"]; !ok { + t.Error("security.folderTrust was dropped") + } +} + +// TestEnsureBobAuthSettingsIsIdempotent verifies re-assertion on every launch +// is cheap and stable: an already-correct file is left byte-identical, so +// repeated launches neither churn mtime nor race each other. +func TestEnsureBobAuthSettingsIsIdempotent(t *testing.T) { + home := t.TempDir() + m := &Manager{logger: discardLogger()} + + m.ensureBobAuthSettings("tester", home) + first, err := os.ReadFile(bobSettingsPath(home)) + if err != nil { + t.Fatalf("read: %v", err) + } + + m.ensureBobAuthSettings("tester", home) + second, err := os.ReadFile(bobSettingsPath(home)) + if err != nil { + t.Fatalf("read: %v", err) + } + + if string(first) != string(second) { + t.Errorf("second call rewrote the file:\nfirst = %s\nsecond = %s", first, second) + } +} + +// TestEnsureBobAuthSettingsCorruptFile ensures a truncated/garbage file does +// not wedge every bob agent forever — it is rebuilt rather than propagated. +func TestEnsureBobAuthSettingsCorruptFile(t *testing.T) { + home := t.TempDir() + writeBobSettings(t, home, `{"security": {"auth": `) + + m := &Manager{logger: discardLogger()} + m.ensureBobAuthSettings("tester", home) + + assertAPIKeyAuth(t, authBlock(t, readBobSettings(t, home))) +} + +func TestEnsureBobAuthSettingsMalformedJSONRewritesWithSharedMode(t *testing.T) { + home := t.TempDir() + writeBobSettings(t, home, `{"security":`) + if err := os.Chmod(bobSettingsPath(home), 0o600); err != nil { + t.Fatalf("chmod seed settings: %v", err) + } + + m := &Manager{logger: discardLogger()} + m.ensureBobAuthSettings("tester", home) + + assertAPIKeyAuth(t, authBlock(t, readBobSettings(t, home))) + info, err := os.Stat(bobSettingsPath(home)) + if err != nil { + t.Fatalf("stat settings: %v", err) + } + if got := info.Mode().Perm(); got != config.BobSettingsFileMode { + t.Errorf("rewritten malformed settings mode = %o, want %o", got, config.BobSettingsFileMode) + } +} + +// TestEnsureBobAuthSettingsWrongTypes covers intermediates of the wrong JSON +// type. There is no sane merge, so they are replaced rather than crashing. +func TestEnsureBobAuthSettingsWrongTypes(t *testing.T) { + for name, raw := range map[string]string{ + "security is a string": `{"security": "yes"}`, + "auth is a number": `{"security": {"auth": 7}}`, + "auth is null": `{"security": {"auth": null}}`, + } { + t.Run(name, func(t *testing.T) { + home := t.TempDir() + writeBobSettings(t, home, raw) + + m := &Manager{logger: discardLogger()} + m.ensureBobAuthSettings("tester", home) + + assertAPIKeyAuth(t, authBlock(t, readBobSettings(t, home))) + }) + } +} + +// TestEnsureBobAuthSettingsGroupWritable pins the mode. The file is SHARED at +// /data/home/.bob (drwxrwx--- dev:node) and bob runs as agent UIDs in group +// node, so it must stay group-writable or the next agent's own startup write +// fails. This also documents why the file is NOT made read-only. +func TestEnsureBobAuthSettingsGroupWritable(t *testing.T) { + home := t.TempDir() + // Pre-create with a tight mode: WriteFile's perm argument only applies at + // CREATE time, so without an explicit chmod this would stay 0600. + writeBobSettings(t, home, `{"security":{"auth":{"selectedType":"sso"}}}`) + if err := os.Chmod(bobSettingsPath(home), 0o600); err != nil { + t.Fatalf("chmod: %v", err) + } + + m := &Manager{logger: discardLogger()} + m.ensureBobAuthSettings("tester", home) + + info, err := os.Stat(bobSettingsPath(home)) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := info.Mode().Perm(); got != config.BobSettingsFileMode { + t.Errorf("mode = %o, want %o", got, config.BobSettingsFileMode) + } +} + +// TestBobSettingsPathMatchesBundle pins the location bob itself computes: +// path.join(os.homedir(), ".bob", "settings.json"). +func TestBobSettingsPathMatchesBundle(t *testing.T) { + if got, want := bobSettingsPath("/data/home"), "/data/home/.bob/settings.json"; got != want { + t.Errorf("bobSettingsPath() = %q, want %q", got, want) + } + if got, want := bobV2SettingsPath("/data/home"), "/data/home/.bob/settings/settings.json"; got != want { + t.Errorf("bobV2SettingsPath() = %q, want %q", got, want) + } + // The shared HOME must match what agentEnvPairs exports, or hive would + // write the auth block to a file bob never reads. + if bobSharedHome != "/data/home" { + t.Errorf("bobSharedHome = %q, want /data/home", bobSharedHome) + } +} + +// TestSetBobAuthBlockReportsChange guards the change-detection that makes the +// idempotent path a no-op. +func TestSetBobAuthBlockReportsChange(t *testing.T) { + tests := []struct { + name string + settings map[string]any + want bool + }{ + {"empty needs write", map[string]any{}, true}, + {"sso needs write", map[string]any{ + config.BobSettingsSecurityKey: map[string]any{ + config.BobSettingsAuthKey: map[string]any{ + config.BobSettingsSelectedTypeKey: "sso", + }, + }, + }, true}, + {"only selectedType set still needs enforcedType", map[string]any{ + config.BobSettingsSecurityKey: map[string]any{ + config.BobSettingsAuthKey: map[string]any{ + config.BobSettingsSelectedTypeKey: config.BobAuthTypeAPIKey, + }, + }, + }, true}, + {"already correct is a no-op", map[string]any{ + config.BobSettingsSecurityKey: map[string]any{ + config.BobSettingsAuthKey: map[string]any{ + config.BobSettingsSelectedTypeKey: config.BobAuthTypeAPIKey, + config.BobSettingsEnforcedTypeKey: config.BobAuthTypeAPIKey, + }, + }, + }, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := setBobAuthBlock(tc.settings); got != tc.want { + t.Errorf("setBobAuthBlock() = %v, want %v", got, tc.want) + } + // Regardless of the return, the end state must be correct. + assertAPIKeyAuth(t, authBlock(t, tc.settings)) + }) + } +} + +// TestVerifyBobKeyReadable is the regression guard for the PRIMARY production +// bug: a dev-owned mode-0600 key file that the hive process can read but the +// agent UID cannot. ResolveAPIKey succeeds and the dashboard shows "key set", +// while bob gets EACCES and silently falls back to the SSO prompt. +func TestVerifyBobKeyReadable(t *testing.T) { + tests := []struct { + name string + dirMode os.FileMode + fileMode os.FileMode + uid int + want bool + }{ + {"the production bug: 0700 dir, 0600 file", 0o700, 0o600, 2004, false}, + {"file readable but dir not traversable", 0o700, 0o640, 2004, false}, + {"dir traversable but file unreadable", 0o710, 0o600, 2004, false}, + {"the fix: 0710 dir, 0640 file", 0o710, 0o640, 2004, true}, + {"world-readable still works", 0o711, 0o644, 2004, true}, + {"root agent bypasses the check", 0o700, 0o600, 0, true}, + {"unset uid bypasses the check", 0o700, 0o600, -1, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + keyDir := filepath.Join(dir, "secrets") + if err := os.MkdirAll(keyDir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + keyPath := filepath.Join(keyDir, "bob_api_key") + if err := os.WriteFile(keyPath, []byte("not-a-real-key"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.Chmod(keyPath, tc.fileMode); err != nil { + t.Fatalf("chmod file: %v", err) + } + if err := os.Chmod(keyDir, tc.dirMode); err != nil { + t.Fatalf("chmod dir: %v", err) + } + // Restore traversal so t.TempDir cleanup can remove the tree. + t.Cleanup(func() { _ = os.Chmod(keyDir, 0o700) }) + + m := &Manager{logger: discardLogger()} + if got := m.verifyBobKeyReadable("tester", keyPath, tc.uid); got != tc.want { + t.Errorf("verifyBobKeyReadable() = %v, want %v", got, tc.want) + } + }) + } +} + +// TestVerifyBobKeyReadableMissingFile ensures a missing key file is reported +// rather than treated as fine. +func TestVerifyBobKeyReadableMissingFile(t *testing.T) { + m := &Manager{logger: discardLogger()} + if m.verifyBobKeyReadable("tester", filepath.Join(t.TempDir(), "nope"), 2004) { + t.Error("verifyBobKeyReadable() = true for a missing file, want false") + } +} + +// TestVerifyBobKeyReadableNoPath covers the env-var key case: there is no file +// to permission-check, so the probe must not report a false failure. +func TestVerifyBobKeyReadableNoPath(t *testing.T) { + m := &Manager{logger: discardLogger()} + if !m.verifyBobKeyReadable("tester", "", 2004) { + t.Error("verifyBobKeyReadable() = false for an env-var key, want true") + } +} + +// TestBobKeyFilePathParsesSource pins the source-string contract: only +// "file:" sources yield a path to permission-check. +func TestBobKeyFilePathParsesSource(t *testing.T) { + tests := []struct { + source string + want string + }{ + {"file:/data/secrets/bob_api_key", "/data/secrets/bob_api_key"}, + {"env:HIVE_BOB_API_KEY", ""}, + {"", ""}, + } + for _, tc := range tests { + t.Run(tc.source, func(t *testing.T) { + m := &Manager{logger: discardLogger()} + src := tc.source + m.SetBobKeySourceResolver(func() string { return src }) + if got := m.bobKeyFilePath(); got != tc.want { + t.Errorf("bobKeyFilePath() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestBobKeyFilePathNoResolver covers the tests/bare-setup path where no +// resolver was injected. +func TestBobKeyFilePathNoResolver(t *testing.T) { + m := &Manager{logger: discardLogger()} + if got := m.bobKeyFilePath(); got != "" { + t.Errorf("bobKeyFilePath() = %q with no resolver, want empty", got) + } +} + +// TestVerifyBobStateDirsWritable pins the probe behind issue #2253: bob +// reports "error saving your latest settings changes" and "Failed to +// initialize logger:" only inside its own TUI, so hive must detect the +// underlying permission problem itself. +// +// The modes are the real ones: 0770 is what /data/home/.bob actually carries +// in production (drwxrwx--- dev:node), and 0700 is the failure mode where the +// dir is owned by dev but carries no group bits, which is exactly what locks +// out an agent UID that reaches it through group `node`. +func TestVerifyBobStateDirsWritable(t *testing.T) { + tests := []struct { + name string + mode os.FileMode + uid int + wantUnwritable bool + }{ + {"production-correct 0770 is writable", 0o770, 2004, false}, + {"0700 locks out the agent UID", 0o700, 2004, true}, + {"0750 is readable but not writable", 0o750, 2004, true}, + {"0760 lacks group traverse", 0o760, 2004, true}, + {"root agent bypasses the check", 0o700, 0, false}, + {"unset uid bypasses the check", 0o700, -1, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + bobDir := filepath.Dir(bobSettingsPath(home)) + if err := os.MkdirAll(bobDir, 0o770); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.Chmod(bobDir, tc.mode); err != nil { + t.Fatalf("chmod: %v", err) + } + // Restore traversal so t.TempDir cleanup can remove the tree. + t.Cleanup(func() { _ = os.Chmod(bobDir, 0o770) }) + + m := &Manager{logger: discardLogger()} + // workDir points at a non-existent path on purpose: a state dir + // bob has not created yet must NOT be reported, PROVIDED its + // parent is group-writable so bob's recursive mkdir can succeed. + // The parent is made 0770 explicitly (t.TempDir is 0700) to keep + // this case isolating the $HOME/.bob mode under test — otherwise + // the unwritable-parent check would fire on every row. + workRoot := t.TempDir() + if err := os.Chmod(workRoot, 0o770); err != nil { + t.Fatalf("chmod workRoot: %v", err) + } + got := m.verifyBobStateDirsWritable("tester", home, + filepath.Join(workRoot, "agents", "tester"), tc.uid) + + if gotUnwritable := len(got) > 0; gotUnwritable != tc.wantUnwritable { + t.Errorf("verifyBobStateDirsWritable() unwritable=%v (%v), want %v", + gotUnwritable, got, tc.wantUnwritable) + } + }) + } +} + +// TestVerifyBobStateDirsWritableIgnoresMissingDirsWithWritableParent covers the +// fresh-PVC case where the state dirs do not exist yet but their parents ARE +// group-writable. bob creates them with mkdirSync recursive on first use, so a +// missing dir with a writable parent is not a fault and must not be reported. +func TestVerifyBobStateDirsWritableIgnoresMissingDirsWithWritableParent(t *testing.T) { + home := t.TempDir() + workDir := t.TempDir() + // t.TempDir() is 0700; production parents are group-writable (2775). + for _, d := range []string{home, workDir} { + if err := os.Chmod(d, 0o770); err != nil { + t.Fatalf("chmod %s: %v", d, err) + } + } + m := &Manager{logger: discardLogger()} + got := m.verifyBobStateDirsWritable("tester", home, workDir, 2004) + if len(got) != 0 { + t.Errorf("verifyBobStateDirsWritable() = %v for missing dirs with writable parents, want none", got) + } +} + +// TestVerifyBobStateDirsWritableFlagsMissingDirWithUnwritableParent is the +// regression test for #2284. On the reporting hive $HOME/.bob did not exist and +// $HOME itself was root-owned 0755, so bob's first-run mkdir failed with EACCES. +// The original probe skipped missing dirs outright and therefore reported "all +// clear" for exactly the failure it was added to catch. +func TestVerifyBobStateDirsWritableFlagsMissingDirWithUnwritableParent(t *testing.T) { + home := t.TempDir() + workDir := t.TempDir() + // 0755: world-readable, NOT group-writable — the production $HOME mode. + if err := os.Chmod(home, 0o755); err != nil { + t.Fatalf("chmod home: %v", err) + } + if err := os.Chmod(workDir, 0o770); err != nil { + t.Fatalf("chmod workDir: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(home, 0o770) }) + + m := &Manager{logger: discardLogger()} + got := m.verifyBobStateDirsWritable("tester", home, workDir, 2004) + wantDir := filepath.Dir(bobSettingsPath(home)) + wantV2Dir := filepath.Dir(bobV2SettingsPath(home)) + if len(got) != 2 || got[0] != wantDir || got[1] != wantV2Dir { + t.Errorf("verifyBobStateDirsWritable() = %v, want [%s %s]", got, wantDir, wantV2Dir) + } +} + +func TestVerifyBobStateDirsWritableFlagsMissingNestedParent(t *testing.T) { + home := t.TempDir() + homeBob := filepath.Dir(bobSettingsPath(home)) + if err := os.MkdirAll(homeBob, 0o770); err != nil { + t.Fatalf("mkdir home .bob: %v", err) + } + if err := os.Chmod(homeBob, 0o770); err != nil { + t.Fatalf("chmod home .bob: %v", err) + } + + workRoot := t.TempDir() + if err := os.Chmod(workRoot, 0o750); err != nil { + t.Fatalf("chmod workRoot: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(workRoot, 0o770) }) + workDir := filepath.Join(workRoot, "agents", "tester") + + m := &Manager{logger: discardLogger()} + got := m.verifyBobStateDirsWritable("tester", home, workDir, 2004) + wantDir := filepath.Join(workDir, config.BobStateDirName) + if len(got) != 1 || got[0] != wantDir { + t.Errorf("verifyBobStateDirsWritable() = %v, want [%s]", got, wantDir) + } +} + +// TestVerifyBobStateDirsWritableFlagsWorkspaceDir pins that the per-agent +// workspace .bob (the "Failed to initialize logger:" target, seen in +// production at /data/agents//.bob/.bob-errors/) is probed too, not just +// the shared $HOME/.bob. +func TestVerifyBobStateDirsWritableFlagsWorkspaceDir(t *testing.T) { + home := t.TempDir() + homeBob := filepath.Dir(bobSettingsPath(home)) + if err := os.MkdirAll(homeBob, 0o770); err != nil { + t.Fatalf("mkdir home .bob: %v", err) + } + // MkdirAll's mode is masked by umask, so set the production mode + // explicitly — this dir must be the WRITABLE one for the assertion below + // to prove the workspace dir is probed independently. + if err := os.Chmod(homeBob, 0o770); err != nil { + t.Fatalf("chmod home .bob: %v", err) + } + workDir := t.TempDir() + stateDir := filepath.Join(workDir, config.BobStateDirName) + if err := os.MkdirAll(stateDir, 0o700); err != nil { + t.Fatalf("mkdir workspace .bob: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(stateDir, 0o770) }) + + m := &Manager{logger: discardLogger()} + got := m.verifyBobStateDirsWritable("tester", home, workDir, 2004) + if len(got) != 1 || got[0] != stateDir { + t.Errorf("verifyBobStateDirsWritable() = %v, want [%s]", got, stateDir) + } +} + +func TestVerifyBobStateDirsWritableIgnoresNonDirectoryStatePath(t *testing.T) { + home := t.TempDir() + homeBob := filepath.Dir(bobSettingsPath(home)) + if err := os.MkdirAll(homeBob, 0o770); err != nil { + t.Fatalf("mkdir home .bob: %v", err) + } + if err := os.Chmod(homeBob, 0o770); err != nil { + t.Fatalf("chmod home .bob: %v", err) + } + workDir := t.TempDir() + statePath := filepath.Join(workDir, config.BobStateDirName) + if err := os.WriteFile(statePath, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("write workspace .bob file: %v", err) + } + + m := &Manager{logger: discardLogger()} + got := m.verifyBobStateDirsWritable("tester", home, workDir, 2004) + if len(got) != 0 { + t.Errorf("verifyBobStateDirsWritable() = %v for non-directory state path, want advisory warning only", got) + } +} diff --git a/agent/bob_startup_kick_test.go b/agent/bob_startup_kick_test.go new file mode 100644 index 0000000..4806721 --- /dev/null +++ b/agent/bob_startup_kick_test.go @@ -0,0 +1,311 @@ +package agent + +import ( + "strings" + "testing" + "time" + + "github.com/hivecommons/crux/config" +) + +// TestBackendDefersStartupKick pins WHICH backends get their bootstrap prompt +// delivered by deliverStartupKick versus embedded in the launch command. +// +// bob and pi are the fixes: without deferral, their bootstrap prompts are +// written to /tmp/.hive-bootstrap-.txt and never read, leaving them idle. +// +// The other rows are regression guards. goose in particular MUST stay false — +// `goose run` needs the embedded --text prompt to stay interactive and exits on +// the ^C that readiness-gated delivery sends. +func TestBackendDefersStartupKick(t *testing.T) { + tests := []struct { + name string + backend string + want bool + why string + }{ + {"bob defers (the fix)", bobBackend, true, + "bob is an interactive TUI with no embedded-prompt mechanism"}, + {"pi defers (the fix)", "pi", true, + "pi is an interactive TUI with no embedded-prompt mechanism"}, + {"claude unchanged", "claude", true, + "pre-existing deferred backend"}, + {"copilot unchanged", "copilot", true, + "pre-existing deferred backend"}, + {"gemini unchanged", "gemini", true, + "pre-existing deferred backend"}, + {"goose still embeds", "goose", false, + "goose run needs --text and exits on the ^C a deferred kick sends"}, + {"codex still embeds", codexBackend, false, + "not a deferred backend; must not change"}, + {"aider still embeds", "aider", false, + "not a deferred backend; must not change"}, + {"unknown backend does not defer", "totally-made-up", false, + "no verified readiness signal for unknown CLIs"}, + {"empty backend does not defer", "", false, + "guard against a zero-value backend silently deferring"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := backendDefersStartupKick(tc.backend); got != tc.want { + t.Errorf("backendDefersStartupKick(%q) = %v, want %v (%s)", + tc.backend, got, tc.want, tc.why) + } + }) + } +} + +// TestBobPaneMarkers pins the literals copied from the installed bobshell +// bundle (1.0.6, bundle/bob.js). They are vendor-defined, not ours: if a bob +// upgrade changes them, bob's readiness detection breaks silently (its startup +// kick is dropped after cliReadyTimeout) and this test is the tripwire. +func TestBobPaneMarkers(t *testing.T) { + tests := []struct { + name string + got string + want string + }{ + {"input placeholder (primary readiness signal)", bobInputPlaceholder, + "Type your message or @path/to/file"}, + {"product marker (coarse CLI presence only)", bobProductMarker, "Bob-Shell"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Errorf("marker = %q, want %q", tc.got, tc.want) + } + }) + } + + // Both markers must be reachable through the shared CLI-presence check, + // otherwise waitForCLIReadyForAgent can never see a booted bob. + for _, marker := range []string{bobInputPlaceholder, bobProductMarker} { + if !paneHasCLIMarker("some pane text " + marker + " trailing") { + t.Errorf("paneHasCLIMarker must recognise bob marker %q", marker) + } + } +} + +// TestPaneShowsInputPrompt covers the readiness gate that decides whether a +// startup kick is typed or dropped. +// +// The claude/copilot/gemini/goose rows assert the pre-existing markers still +// match EXACTLY as before — this is the test that proves adding bob did not +// change the other backends' readiness behaviour. +func TestPaneShowsInputPrompt(t *testing.T) { + tests := []struct { + name string + pane string + want bool + }{ + // --- bob: the new behaviour --- + {"bob idle input box", " " + bobInputPlaceholder, true}, + {"bob placeholder amid other output", "booting\n " + bobInputPlaceholder + "\n", true}, + + // --- pre-existing markers: must be unchanged --- + {"claude/copilot/gemini arrow prompt", "some output\n❯ ", true}, + {"goose ready banner", "goose is ready", true}, + {"enter-to-send prompt", "> Enter to send", true}, + {"bare newline-gt-newline prompt", "text\n>\nmore", true}, + + // --- negatives --- + {"empty pane", "", false}, + {"bare bash prompt", "user@host:~$ ", false}, + {"bob product name alone is NOT input-ready", bobProductMarker, false}, + {"bob license dialog is NOT input-ready", + bobProductMarker + " API Key Notice\nBy using the " + bobProductMarker + " API", false}, + {"unrelated prose", "the build finished successfully", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := paneShowsInputPrompt(tc.pane); got != tc.want { + t.Errorf("paneShowsInputPrompt(%q) = %v, want %v", tc.pane, got, tc.want) + } + }) + } +} + +// TestBobConsentScreenNoFalsePositive guards the OTHER half of the readiness +// chain. paneShowsConsentScreen makes waitForInputPromptForAgent skip a pane, +// and its markers are Claude-specific ("Enter to confirm", "Bypass Permissions +// mode"). Verified against bobshell 1.0.6: the bundle contains none of them, so +// a bob pane must never be mistaken for a consent screen — if it were, bob's +// kick would be dropped exactly as it was before this fix. +func TestBobConsentScreenNoFalsePositive(t *testing.T) { + tests := []struct { + name string + pane string + }{ + {"bob idle prompt", " " + bobInputPlaceholder}, + {"bob banner", bobProductMarker + " 1.0.6"}, + {"bob trust dialog text", + "Trusting a folder allows " + bobProductMarker + " to read and perform auto-edits"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if paneShowsConsentScreen(tc.pane) { + t.Errorf("paneShowsConsentScreen(%q) = true; a bob pane must never "+ + "be treated as a consent screen or its startup kick is dropped", tc.pane) + } + }) + } +} + +// TestBobInputHandlerSettleDelay pins the bob-only settle window and asserts it +// is genuinely distinct from the generic tmux pacing constants. bob's Ink +// reconciler paints the input box before attaching its stdin handler, so text +// typed too early is painted but never reaches component state. +func TestBobInputHandlerSettleDelay(t *testing.T) { + if bobInputHandlerSettleDelay <= 0 { + t.Fatalf("bobInputHandlerSettleDelay must be positive, got %v", bobInputHandlerSettleDelay) + } + // It must not silently collapse into a value that "happens to work" and is + // really just tmux keystroke pacing. + for _, other := range []struct { + name string + d time.Duration + }{ + {"textToEnterDelay", textToEnterDelay}, + {"staleCheckDelay", staleCheckDelay}, + } { + if bobInputHandlerSettleDelay == other.d { + t.Errorf("bobInputHandlerSettleDelay (%v) must be a deliberate, distinct "+ + "value, not a reuse of %s (%v)", bobInputHandlerSettleDelay, other.name, other.d) + } + } + // The settle window must fit comfortably inside the gate that precedes it, + // or a slow bob would blow the overall startup-kick budget. + if bobInputHandlerSettleDelay >= inputPromptTimeout { + t.Errorf("bobInputHandlerSettleDelay (%v) must be far below inputPromptTimeout (%v)", + bobInputHandlerSettleDelay, inputPromptTimeout) + } +} + +// TestBobStartupKickNotWrittenToDeadTempFile documents that bob no longer takes +// the write-a-file-and-never-read-it path. Only backends that actually consume +// the file (goose, via --text "$(cat ...)") should be writing it. +// +// Asserted through the launch-command builder: bob's command must contain no +// reference to the bootstrap temp file and no embedded prompt argument. +func TestBobStartupKickNotWrittenToDeadTempFile(t *testing.T) { + cmd := bobLaunchCmd("bob") + for _, forbidden := range []string{".hive-bootstrap", "--text", "$(cat", "-p ", "--prompt"} { + if strings.Contains(cmd, forbidden) { + t.Errorf("bobLaunchCmd() = %q must not contain %q: bob's prompt is delivered "+ + "by deliverStartupKick, not embedded or read from a temp file", cmd, forbidden) + } + } +} + +// TestDeliverStartupKick_BobReceivesKick is the end-to-end proof that the +// prompt actually reaches bob, rather than merely that a function was called. +// +// It drives the real deliverStartupKick against a real tmux pane rendering +// bob's real idle placeholder, and asserts the kick was delivered: LastKick +// set, the exact prompt text recorded, and history appended. Before this fix +// the pane would never satisfy waitForCLIReadyForAgent (bob renders none of +// the pre-existing markers) and the kick would be dropped. +func TestDeliverStartupKick_BobReceivesKick(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + const session = "hive-bobkick1" + newRawTmuxSession(t, session) + + m := NewManager(map[string]config.AgentConfig{ + session: {Backend: bobBackend}, + }, discardLogger(), ProjectContext{}) + + m.mu.RLock() + agent := m.agents[session] + m.mu.RUnlock() + if agent == nil { + t.Fatalf("agent %q not registered", session) + } + agent.tmuxSession = session + agent.State = StateRunning + agent.launchGen = 1 + + // Render EXACTLY what a booted, idle bob shows — no "❯", no goose banner. + paneInject(t, session, bobInputPlaceholder) + // Don't start the readiness clock until tmux has actually painted the + // placeholder: on a loaded runner the render can outlast paneInject's fixed + // sleep AND the TestMain-shrunk cliReadyTimeout, dropping the kick and + // flaking this test for reasons unrelated to the delivery path under test. + requirePaneShows(t, session, bobInputPlaceholder) + + const prompt = "bootstrap: go find work" + m.deliverStartupKick(agent, prompt, 1) + + if agent.LastKick == nil { + t.Fatal("bob must receive its startup kick; LastKick is nil " + + "(the prompt was dropped, which is the bug this change fixes)") + } + if agent.LastKickMessage != prompt { + t.Errorf("LastKickMessage = %q, want %q", agent.LastKickMessage, prompt) + } + if len(agent.KickHistory) == 0 { + t.Error("bob's startup kick must be recorded in KickHistory") + } +} + +// TestDeliverStartupKick_PausedAgentNotKicked covers the paused-agent case. +// +// An agent restored as paused never reaches launchInTmux at all (Start returns +// early with State=StatePaused), so no startup kick is ever spawned. This test +// pins the second line of defence: even if a kick goroutine is somehow in +// flight, deliverStartupKick's state check must refuse to deliver to an agent +// that is not StateRunning. +func TestDeliverStartupKick_PausedAgentNotKicked(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + + tests := []struct { + name string + session string + backend string + }{ + {"paused bob is not kicked", "hive-bobpause1", bobBackend}, + {"paused claude is not kicked", "hive-bobpause2", "claude"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + newRawTmuxSession(t, tc.session) + name := tc.session + m := NewManager(map[string]config.AgentConfig{ + name: {Backend: tc.backend}, + }, discardLogger(), ProjectContext{}) + + m.mu.RLock() + agent := m.agents[name] + m.mu.RUnlock() + if agent == nil { + t.Fatalf("agent %q not registered", name) + } + agent.tmuxSession = tc.session + agent.Paused = true + agent.State = StatePaused + agent.launchGen = 1 + + // Render a ready prompt so the readiness gates pass fast and the + // state check — not a timeout — is what stops delivery. + paneInject(t, tc.session, "goose is ready") + + m.deliverStartupKick(agent, "bootstrap prompt", 1) + + if agent.LastKick != nil { + t.Error("a paused agent must not receive a startup kick") + } + if len(agent.KickHistory) != 0 { + t.Errorf("a paused agent must record no kick history, got %d entries", + len(agent.KickHistory)) + } + }) + } +} diff --git a/agent/bob_test.go b/agent/bob_test.go new file mode 100644 index 0000000..be93414 --- /dev/null +++ b/agent/bob_test.go @@ -0,0 +1,499 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hivecommons/crux/config" +) + +// TestWatchedHomeDirsIncludesBob pins the fix for bob's startup failure: +// +// EACCES: permission denied, mkdir '/data/home/.bob' +// +// bob derives its state dir as path.join(os.homedir(), ".bob") and agents run +// with HOME=/data/home, which is root-created — so the watcher must own it. +// +// It checks productionWatchedHomeDirs — the snapshot TestMain takes before +// pointing WatchedHomeDirs into the hermetic temp tree — so the pin stays on +// the production default, not the test override. +func TestWatchedHomeDirsIncludesBob(t *testing.T) { + const bobStateDir = "/data/home/.bob" + for _, dir := range productionWatchedHomeDirs { + if dir == bobStateDir { + return + } + } + t.Fatalf("WatchedHomeDirs must contain %q so bob can create its state dir; got %v", + bobStateDir, productionWatchedHomeDirs) +} + +// TestPermissionsWatcher_BobNestedTree covers the nested case explicitly: bob +// writes .bob/tmp//chats, so a fix that only handled the top level would +// leave the chat-recording service broken. ensureWatchedDirs must create the +// root, and fixPermissions must walk (and correct) arbitrarily deep children. +func TestPermissionsWatcher_BobNestedTree(t *testing.T) { + tests := []struct { + name string + // nested is created before the watcher runs, relative to the .bob root. + nested string + // file, when set, is created inside nested with restrictive perms. + file string + }{ + {name: "top level only", nested: ""}, + {name: "tmp subdir", nested: "tmp"}, + {name: "chats tree", nested: filepath.Join("tmp", "abc-123", "chats")}, + {name: "chats tree with file", nested: filepath.Join("tmp", "def-456", "chats"), file: "session.json"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + bobRoot := filepath.Join(dir, ".bob") + + target := bobRoot + if tc.nested != "" { + target = filepath.Join(bobRoot, tc.nested) + } + // 0o700 is what bob's mkdirSync default would leave behind: unusable + // by other agent UIDs in the shared node group. + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatal(err) + } + if tc.file != "" { + if err := os.WriteFile(filepath.Join(target, tc.file), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + } + + origW, origG := WatchedHomeDirs, GooseLogsDir + WatchedHomeDirs = []string{bobRoot} + GooseLogsDir = filepath.Join(dir, "goose", "logs") + t.Cleanup(func() { WatchedHomeDirs = origW; GooseLogsDir = origG }) + + // Record every path the walk should visit, so we can assert the walk + // actually reaches the full depth of bob's tree. + var walked []string + if err := filepath.Walk(bobRoot, func(p string, fi os.FileInfo, err error) error { + if err == nil { + walked = append(walked, p) + } + return nil + }); err != nil { + t.Fatal(err) + } + + ensureWatchedDirs(discardLogger()) + fixPermissions(discardLogger()) + + // The root must exist: ensureWatchedDirs creates it (MkdirAll) with + // group bits, which is what fixes bob's "EACCES: mkdir '/data/home/.bob'". + info, err := os.Stat(bobRoot) + if err != nil { + t.Fatalf("bob state dir should exist: %v", err) + } + if !info.IsDir() { + t.Fatal("bob state dir should be a directory") + } + + // fixPermissions must WALK the whole nested tree, not just the root. + // This is the property that makes .bob/tmp//chats recoverable: + // anything created root-owned underneath is chowned on the next tick. + // + // The mode assertions that would normally follow are deliberately + // omitted: fixEntry only chmods entries owned by DevUID (1001) — see + // the "Only fix permissions on files we own or just chowned" guard — + // and an unprivileged test process owns these files as its own UID, + // so production behavior here is correctly a no-op. Asserting modes + // would require running as uid 1001 or as root. + if tc.nested != "" { + if _, err := os.Stat(target); err != nil { + t.Fatalf("nested dir %q should exist: %v", tc.nested, err) + } + wantDepth := len(strings.Split(tc.nested, string(filepath.Separator))) + // root + one entry per nested level (+1 more when a file exists). + minWalked := 1 + wantDepth + if tc.file != "" { + minWalked++ + } + if len(walked) < minWalked { + t.Errorf("walk visited %d paths (%v), want at least %d — the nested "+ + "tree must be traversed, not just the root", len(walked), walked, minWalked) + } + } + if tc.file != "" { + if _, err := os.Stat(filepath.Join(target, tc.file)); err != nil { + t.Fatalf("nested file should exist: %v", err) + } + } + }) + } +} + +// TestBobAPIKeyResolver covers the resolver seam: absent when never injected, +// absent when the resolver reports no key, present when it does. +func TestBobAPIKeyResolver(t *testing.T) { + tests := []struct { + name string + inject bool + resolver func() string + want string + }{ + {name: "no resolver injected", inject: false, want: ""}, + {name: "nil resolver injected", inject: true, resolver: nil, want: ""}, + {name: "resolver returns empty", inject: true, resolver: func() string { return "" }, want: ""}, + {name: "resolver returns key", inject: true, resolver: func() string { return "bob-key-abc" }, want: "bob-key-abc"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := &Manager{logger: discardLogger()} + if tc.inject { + m.SetBobAPIKeyResolver(tc.resolver) + } + if got := m.bobAPIKey(); got != tc.want { + t.Errorf("bobAPIKey() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestBobEnvPairInjection verifies the key is plumbed into the agent env under +// the names bob 1.x and 2.x read, marked Secret so it never reaches the command +// line, and is confined to the bob backend. +func TestBobEnvPairInjection(t *testing.T) { + tests := []struct { + name string + backend string + key string + wantPair bool + wantSecret bool + }{ + {name: "bob with key", backend: "bob", key: "bob-key-abc", wantPair: true, wantSecret: true}, + {name: "bob without key", backend: "bob", key: "", wantPair: false}, + {name: "claude never gets bob key", backend: "claude", key: "bob-key-abc", wantPair: false}, + {name: "copilot never gets bob key", backend: "copilot", key: "bob-key-abc", wantPair: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := &Manager{logger: discardLogger()} + key := tc.key + m.SetBobAPIKeyResolver(func() string { return key }) + + agent := &AgentProcess{ + Name: "tester", + Config: config.AgentConfig{Backend: tc.backend}, + } + + found := map[string]agentEnvPair{} + for _, p := range m.agentEnvPairs(agent) { + if p.Key == config.BobAPIKeyEnvVar || p.Key == config.BobV2APIKeyEnvVar { + found[p.Key] = p + } + } + + for _, envName := range []string{config.BobAPIKeyEnvVar, config.BobV2APIKeyEnvVar} { + p, ok := found[envName] + if tc.wantPair != ok { + t.Fatalf("%s pair present = %v, want %v", envName, ok, tc.wantPair) + } + if !ok { + continue + } + if p.Value != tc.key { + t.Errorf("%s pair value = %q, want %q", envName, p.Value, tc.key) + } + if p.Secret != tc.wantSecret { + t.Errorf("%s pair Secret = %v, want %v (a key must never reach the command line)", + envName, p.Secret, tc.wantSecret) + } + } + }) + } +} + +// TestBobAuthTypeEnvPair guards one of the three layers that keep bob off the +// SSO prompt. BOBSHELL_DEFAULT_AUTH_TYPE is the weakest of them — per +// bundle/bob.js it is only the FALLBACK default and loses to a persisted +// security.auth.selectedType — but it still must be present on bob agents and +// absent everywhere else. The stronger layers are --auth-method (see +// TestBobLaunchCmd) and the settings file (see TestEnsureBobAuthSettings). +func TestBobAuthTypeEnvPair(t *testing.T) { + tests := []struct { + name string + backend string + key string + wantPair bool + }{ + {"bob backend with key", "bob", "bob-key", true}, + // The auth type is not a credential, so it does not depend on the key + // being resolvable — bob must still be told not to use SSO. + {"bob backend without key", "bob", "", true}, + {"claude backend", "claude", "bob-key", false}, + {"copilot backend", "copilot", "bob-key", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := &Manager{logger: discardLogger()} + key := tc.key + m.SetBobAPIKeyResolver(func() string { return key }) + + agent := &AgentProcess{ + Name: "tester", + Config: config.AgentConfig{Backend: tc.backend}, + } + + var found *agentEnvPair + for _, p := range m.agentEnvPairs(agent) { + if p.Key == config.BobAuthTypeEnvVar { + pair := p + found = &pair + } + } + if tc.wantPair != (found != nil) { + t.Fatalf("%s pair present = %v, want %v", config.BobAuthTypeEnvVar, found != nil, tc.wantPair) + } + if found == nil { + return + } + if found.Value != config.BobAuthTypeAPIKey { + t.Errorf("pair value = %q, want %q", found.Value, config.BobAuthTypeAPIKey) + } + // Must be NON-secret: secret pairs only reach a freshly-created + // pane via tmux set-environment, while non-secret pairs are + // re-applied on every launch through buildEnvPrefix. See #2228. + if found.Secret { + t.Error("auth type must not be Secret: it is not a credential and must be re-applied on every launch") + } + prefix := m.buildEnvPrefix(agent) + want := config.BobAuthTypeEnvVar + "=" + if !strings.Contains(prefix, want) { + t.Errorf("env prefix = %q, want it to contain %q so every launch re-applies it", prefix, want) + } + }) + } +} + +// TestBobKeyNotInEnvPrefix is the regression guard for leaking the key onto the +// shell command line, where it would show up in `ps` and pane scrollback. +func TestBobKeyNotInEnvPrefix(t *testing.T) { + const secretKey = "bob-key-must-not-leak" + m := &Manager{logger: discardLogger()} + m.SetBobAPIKeyResolver(func() string { return secretKey }) + + agent := &AgentProcess{ + Name: "tester", + Config: config.AgentConfig{Backend: "bob"}, + } + prefix := m.buildEnvPrefix(agent) + if strings.Contains(prefix, secretKey) { + t.Errorf("env prefix must not contain the API key value; got %q", prefix) + } + if strings.Contains(prefix, config.BobAPIKeyEnvVar) { + t.Errorf("env prefix must not mention %s at all; got %q", config.BobAPIKeyEnvVar, prefix) + } + if strings.Contains(prefix, config.BobV2APIKeyEnvVar) { + t.Errorf("env prefix must not mention %s at all; got %q", config.BobV2APIKeyEnvVar, prefix) + } +} + +// TestBobLaunchCmd pins the flags that make headless auth work, keep the +// session interactive, and let an unattended agent actually run tool calls. +func TestBobLaunchCmd(t *testing.T) { + wantContain := []string{ + "case \"$(bob --version", + // Clears the license gate that would otherwise hard-error with no + // human to answer it. + "--accept-license", + // The strongest auth control: a real (but --help-hidden) flag in + // bobshell 1.0.6. It must stay in the 1.x branch only. + "--auth-method api-key", + // Without this bob reports `Auto-approve: Off` and blocks forever on + // the first tool call. bobshell 2.x spells the control differently. + "--approval-mode yolo", + "bob chat --accept-license --auto-approve --trust", + // Clears "This folder is not trusted. Some features may be disabled." + "--trust", + } + got := bobLaunchCmd("bob") + for _, want := range wantContain { + if !strings.Contains(got, want) { + t.Errorf("bobLaunchCmd() = %q, want it to contain %q", got, want) + } + } + + wantAbsent := []string{ + // --model is the BUG: normalizeModelName rewrote claude-sonnet-4-6 to + // claude-sonnet-4.6, an id bob's backend does not know, and every + // prompt died with "Cannot read properties of undefined (reading + // 'maxTokens')". bob auto-selects its model; hive must not pass one. + "--model", + // Interactive mode must be preserved. + " -p ", + "--prompt", + } + for _, absent := range wantAbsent { + if strings.Contains(got, absent) { + t.Errorf("bobLaunchCmd() = %q, want it NOT to contain %q", got, absent) + } + } +} + +func TestBobLaunchCmdVersionBranches(t *testing.T) { + if got, want := bobLaunchCmdV1("bob"), + "bob --accept-license --auth-method api-key --approval-mode yolo --trust"; got != want { + t.Fatalf("bobLaunchCmdV1() = %q, want %q", got, want) + } + if got, want := bobLaunchCmdV2("bob"), + "bob chat --accept-license --auto-approve --trust"; got != want { + t.Fatalf("bobLaunchCmdV2() = %q, want %q", got, want) + } + if strings.Contains(bobLaunchCmdV2("bob"), "--auth-method") { + t.Fatalf("bobLaunchCmdV2() must not pass removed --auth-method: %q", bobLaunchCmdV2("bob")) + } + if strings.Contains(bobLaunchCmdV2("bob"), "--approval-mode") { + t.Fatalf("bobLaunchCmdV2() must not pass removed --approval-mode: %q", bobLaunchCmdV2("bob")) + } +} + +func TestBobLaunchCmdSessionLabelDefaultOff(t *testing.T) { + if got, want := bobLaunchCmd("bob", ""), bobLaunchCmd("bob"); got != want { + t.Fatalf("empty bob session label changed launch command: got %q want %q", got, want) + } + if got, want := bobLaunchCmdV1("bob", ""), bobLaunchCmdV1("bob"); got != want { + t.Fatalf("empty label changed v1 command: got %q want %q", got, want) + } + if got, want := bobLaunchCmdV2("bob", ""), bobLaunchCmdV2("bob"); got != want { + t.Fatalf("empty label changed v2 command: got %q want %q", got, want) + } +} + +func TestBobLaunchCmdSessionLabelUsesInstanceID(t *testing.T) { + for name, got := range map[string]string{ + "v1": bobLaunchCmdV1("bob", "hive-scanner"), + "v2": bobLaunchCmdV2("bob", "hive-scanner"), + } { + if !strings.Contains(got, "--instance-id 'hive-scanner'") { + t.Fatalf("%s command = %q, want --instance-id 'hive-scanner'", name, got) + } + } +} + +// TestBobLaunchCmdApprovalIsFlat documents the deliberate design decision that +// bob's approval mode does NOT vary with the agent's ACMM mode. +// +// Every other backend already auto-approves tools at every level (claude gets +// --dangerously-skip-permissions, copilot gets --allow-all, in all three mode +// branches of launchInTmux). Hive contains low-mode agents by denying GitHub +// write tools and unsetting GH_TOKEN/GITHUB_TOKEN, not by making them ask +// permission for local tool calls. bob matches that posture. +func TestBobLaunchCmdApprovalIsFlat(t *testing.T) { + // bobLaunchCmd takes no mode argument at all, so the command is identical + // for every agent. Guard against a future signature change reintroducing + // per-mode approval without updating this reasoning. + if a, b := bobLaunchCmd("bob"), bobLaunchCmd("bob"); a != b { + t.Fatalf("bobLaunchCmd() is not deterministic: %q vs %q", a, b) + } + for _, forbidden := range []string{"--approval-mode default", "--approval-mode auto_edit"} { + if strings.Contains(bobLaunchCmd("bob"), forbidden) { + t.Errorf("bobLaunchCmd() must not use %q: bob matches the fleet's "+ + "flat auto-approve posture", forbidden) + } + } +} + +// TestNormalizeModelNameSkipsBob covers the dot-rewrite that corrupted bob's +// model id. bob is now excluded alongside claude and the inference backends. +// TestToolRulesToLaunchCmdBobHasNoModel guards the second path that could feed +// bob a --model: an agent with Config.Tools set bypasses the launchInTmux +// switch entirely and would otherwise fall to the default branch. +func TestToolRulesToLaunchCmdBobHasNoModel(t *testing.T) { + tools := &config.ToolsConfig{} + got := toolRulesToLaunchCmd("bob", "claude-sonnet-4.6", bobBackend, tools, false, "") + if strings.Contains(got, "--model") { + t.Errorf("toolRulesToLaunchCmd(bob) = %q must not contain --model", got) + } + for _, want := range []string{"--accept-license", "--approval-mode yolo", "--trust"} { + if !strings.Contains(got, want) { + t.Errorf("toolRulesToLaunchCmd(bob) = %q, want it to contain %q", got, want) + } + } + // Non-bob backends must still receive their model. + if c := toolRulesToLaunchCmd("copilot", "gpt-5", "copilot", tools, false, ""); !strings.Contains(c, "--model gpt-5") { + t.Errorf("toolRulesToLaunchCmd(copilot) = %q, want it to contain --model gpt-5", c) + } +} + +// TestBobLaunchRefusedWithoutKey verifies the fail-fast: with no key +// configured, launchInTmux must mark the agent failed and return rather than +// starting bob, which would otherwise hang for 3 minutes on a browser flow +// that cannot complete in a pod. +func TestBobLaunchRefusedWithoutKey(t *testing.T) { + tests := []struct { + name string + key string + wantState ProcessState + }{ + {name: "no key refuses launch", key: "", wantState: StateFailed}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := os.Stat("/usr/local/bin/bob"); err == nil { + t.Skip("bob binary present; this test covers the no-key path only") + } + m := &Manager{logger: discardLogger()} + key := tc.key + m.SetBobAPIKeyResolver(func() string { return key }) + + agent := &AgentProcess{ + Name: "tester", + Config: config.AgentConfig{Backend: "bob"}, + } + // launchInTmux returns nil for both the missing-binary and missing-key + // paths (one bad agent must never abort the fleet); StateFailed is the + // observable signal in either case. + if err := m.launchInTmux(t.Context(), agent); err != nil { + t.Fatalf("launchInTmux should return nil, got %v", err) + } + if agent.State != tc.wantState { + t.Errorf("agent.State = %v, want %v", agent.State, tc.wantState) + } + if agent.HasLaunched { + t.Error("agent must not be marked launched when auth cannot succeed") + } + }) + } +} + +// TestBobBackendAuthAvailable verifies the dashboard sees honest auth state for +// bob (previously "unknown", like every non-claude/copilot backend). +func TestBobBackendAuthAvailable(t *testing.T) { + tests := []struct { + name string + key string + wantAvailable bool + wantKnown bool + }{ + {name: "key configured", key: "bob-key-abc", wantAvailable: true, wantKnown: true}, + {name: "no key configured", key: "", wantAvailable: false, wantKnown: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := &Manager{logger: discardLogger()} + key := tc.key + m.SetBobAPIKeyResolver(func() string { return key }) + + available, known := m.BackendAuthAvailable("bob") + if available != tc.wantAvailable || known != tc.wantKnown { + t.Errorf("BackendAuthAvailable(bob) = (%v, %v), want (%v, %v)", + available, known, tc.wantAvailable, tc.wantKnown) + } + }) + } +} diff --git a/agent/branches_coverage_test.go b/agent/branches_coverage_test.go new file mode 100644 index 0000000..502b62e --- /dev/null +++ b/agent/branches_coverage_test.go @@ -0,0 +1,533 @@ +package agent + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/hivecommons/crux/config" +) + +// --------------------------------------------------------------------------- +// deliverStartupKick — drop branches (stopped / generation mismatch) +// --------------------------------------------------------------------------- + +func TestDeliverStartupKick_DroppedStopped(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + session := "hive-covsk1" + newRawTmuxSession(t, session) + m := NewManager(map[string]config.AgentConfig{"covsk1": {Backend: "claude"}}, discardLogger(), ProjectContext{}) + m.mu.RLock() + agent := m.agents["covsk1"] + m.mu.RUnlock() + agent.tmuxSession = session + agent.State = StateStopped // not running -> dropped after readiness gates + agent.launchGen = 0 + // Render a ready prompt so the wait gates pass quickly. + paneInject(t, session, "goose is ready") + m.deliverStartupKick(agent, "prompt", 0) + if agent.LastKick != nil { + t.Error("startup kick to a stopped agent should be dropped") + } +} + +func TestDeliverStartupKick_DroppedGenMismatch(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + session := "hive-covsk2" + newRawTmuxSession(t, session) + m := NewManager(map[string]config.AgentConfig{"covsk2": {Backend: "claude"}}, discardLogger(), ProjectContext{}) + m.mu.RLock() + agent := m.agents["covsk2"] + m.mu.RUnlock() + agent.tmuxSession = session + agent.State = StateRunning + agent.launchGen = 5 + paneInject(t, session, "goose is ready") + // Pass a stale generation (0 != 5) -> dropped. + m.deliverStartupKick(agent, "prompt", 0) + if agent.LastKick != nil { + t.Error("startup kick with stale generation should be dropped") + } +} + +func TestDeliverStartupKick_StaleGenerationDoesNotClearCurrentDrain(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + session := "hive-covsk-stale-drain" + newRawTmuxSession(t, session) + m := NewManager(map[string]config.AgentConfig{"covsk-stale-drain": {Backend: "claude"}}, discardLogger(), ProjectContext{}) + m.mu.Lock() + agent := m.agents["covsk-stale-drain"] + agent.tmuxSession = session + agent.State = StateRunning + agent.launchGen = 2 + agent.startupKickInFlight = true + agent.startupKickGen = 2 + m.mu.Unlock() + paneInject(t, session, "goose is ready") + requirePaneShows(t, session, "goose is ready") + + m.deliverStartupKick(agent, "stale bootstrap prompt", 1) + + m.mu.RLock() + inFlight := agent.startupKickInFlight + last := agent.LastKick + m.mu.RUnlock() + if !inFlight { + t.Fatal("stale startup kick goroutine cleared the current launch's in-flight guard") + } + if last != nil { + t.Fatal("stale startup kick delivered despite launch generation mismatch") + } +} + +func TestSendKickIgnoresStaleStartupDrainGeneration(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + session := "hive-stale-drain-normal-kick" + newRawTmuxSession(t, session) + m := NewManager(map[string]config.AgentConfig{"stale-drain-normal-kick": {Backend: "claude"}}, discardLogger(), ProjectContext{}) + m.mu.Lock() + agent := m.agents["stale-drain-normal-kick"] + agent.tmuxSession = session + agent.State = StateRunning + agent.launchGen = 2 + agent.startupKickInFlight = true + agent.startupKickGen = 1 + m.mu.Unlock() + paneInject(t, session, "goose is ready") + requirePaneShows(t, session, "goose is ready") + + if err := m.SendKick("stale-drain-normal-kick", "deliver normally after relaunch"); err != nil { + t.Fatalf("SendKick with only a stale startup drain should deliver normally, got error: %v", err) + } + + m.mu.RLock() + inFlight := agent.startupKickInFlight + msg := agent.LastKickMessage + m.mu.RUnlock() + if inFlight { + t.Fatal("stale startup drain flag was not cleared") + } + if msg != "deliver normally after relaunch" { + t.Fatalf("LastKickMessage = %q, want normal delivery instead of stale deferral", msg) + } +} + +func TestDeliverStartupKick_Delivered(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + session := "hive-covsk3" + newRawTmuxSession(t, session) + m := NewManager(map[string]config.AgentConfig{"covsk3": {Backend: "claude"}}, discardLogger(), ProjectContext{}) + m.mu.RLock() + agent := m.agents["covsk3"] + m.mu.RUnlock() + agent.tmuxSession = session + agent.State = StateRunning + agent.launchGen = 3 + paneInject(t, session, "goose is ready") + // Don't start deliverStartupKick's readiness clock until tmux has actually + // painted the marker: on a loaded runner the render can outlast + // paneInject's fixed sleep AND the TestMain-shrunk cliReadyTimeout, + // dropping the kick and flaking this test for reasons unrelated to the + // delivery path under test. Same pattern as + // TestDeliverStartupKick_BobReceivesKick (#4871). + requirePaneShows(t, session, "goose is ready") + m.deliverStartupKick(agent, "bootstrap prompt", 3) + if agent.LastKick == nil { + t.Error("startup kick should be delivered on matching generation") + } +} + +func TestSendKickDuringStartupQueueDeliveredAfterLaunch(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + session := "hive-startup-deferred-stopped" + newRawTmuxSession(t, session) + m := NewManager(map[string]config.AgentConfig{"scanner": {Backend: "claude"}}, discardLogger(), ProjectContext{}) + m.mu.Lock() + agent := m.agents["scanner"] + agent.tmuxSession = session + agent.State = StateStopped + agent.launchGen = 1 + m.mu.Unlock() + + m.MarkStartupLaunchQueued([]string{"scanner"}) + if err := m.SendKick("scanner", "go drain the queue"); err != nil { + t.Fatalf("SendKick while startup-queued should defer, got error: %v", err) + } + if agent.LastKick != nil { + t.Fatal("deferred startup kick was delivered before the agent launched") + } + + paneInject(t, session, "goose is ready") + requirePaneShows(t, session, "goose is ready") + m.mu.Lock() + agent.State = StateRunning + agent.startupKickInFlight = true + agent.startupKickGen = 1 + m.mu.Unlock() + + m.deliverStartupKick(agent, "bootstrap prompt", 1) + + if agent.LastKick == nil { + t.Fatal("deferred startup kick was not delivered after launch") + } + if agent.LastKickMessage != "go drain the queue" { + t.Fatalf("LastKickMessage = %q, want deferred governor kick", agent.LastKickMessage) + } + if len(agent.KickHistory) != 1 { + t.Fatalf("KickHistory length = %d, want exactly one delivered kick", len(agent.KickHistory)) + } + if agent.KickRefused || agent.KickRefusalReason != "" { + t.Fatalf("deferred kick refusal state not cleared: refused=%v reason=%q", agent.KickRefused, agent.KickRefusalReason) + } +} + +func TestStartDrainsDeferredKickWithoutBootstrapPrompt(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + m := NewManager(map[string]config.AgentConfig{ + "scanner": { + Backend: "claude", + LaunchCmd: "printf 'goose is ready\\n'; cat", + }, + }, discardLogger(), ProjectContext{}) + t.Cleanup(func() { _ = testTmuxCommand("kill-session", "-t", "hive-scanner").Run() }) + + m.MarkStartupLaunchQueued([]string{"scanner"}) + if err := m.SendKick("scanner", "go drain the queue from real Start"); err != nil { + t.Fatalf("SendKick while startup-queued should defer, got error: %v", err) + } + if err := m.Start(context.Background(), "scanner"); err != nil { + t.Fatalf("Start: %v", err) + } + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + m.mu.RLock() + agent := m.agents["scanner"] + last := agent.LastKick + msg := agent.LastKickMessage + historyLen := len(agent.KickHistory) + inFlight := agent.startupKickInFlight + m.mu.RUnlock() + if last != nil { + if msg != "go drain the queue from real Start" { + t.Fatalf("LastKickMessage = %q, want deferred kick from real Start", msg) + } + if historyLen != 1 { + t.Fatalf("KickHistory length = %d, want one deferred kick and no bootstrap", historyLen) + } + return + } + if !inFlight { + t.Fatal("startup kick drain stopped before delivering the deferred kick") + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("deferred kick was not drained after Start") +} + +func TestStartDrainsDeferredKickWhenReusingExistingCLI(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + session := "hive-reuse" + newRawTmuxSession(t, session) + m := NewManager(map[string]config.AgentConfig{"reuse": {Backend: "claude"}}, discardLogger(), ProjectContext{}) + paneInject(t, session, "goose is ready") + requirePaneShows(t, session, "goose is ready") + + m.MarkStartupLaunchQueued([]string{"reuse"}) + if err := m.SendKick("reuse", "go drain after reuse"); err != nil { + t.Fatalf("SendKick while startup-queued should defer, got error: %v", err) + } + if err := m.Start(context.Background(), "reuse"); err != nil { + t.Fatalf("Start: %v", err) + } + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + m.mu.RLock() + agent := m.agents["reuse"] + last := agent.LastKick + msg := agent.LastKickMessage + historyLen := len(agent.KickHistory) + m.mu.RUnlock() + if last != nil { + if msg != "go drain after reuse" { + t.Fatalf("LastKickMessage = %q, want deferred kick after existing CLI reuse", msg) + } + if historyLen != 1 { + t.Fatalf("KickHistory length = %d, want one deferred kick", historyLen) + } + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("deferred kick was not drained after existing CLI reuse") +} + +func TestSendKickDuringStartupKickInFlightReplacesBootstrap(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + session := "hive-startup-deferred-running" + newRawTmuxSession(t, session) + m := NewManager(map[string]config.AgentConfig{"scanner": {Backend: "claude"}}, discardLogger(), ProjectContext{}) + m.mu.Lock() + agent := m.agents["scanner"] + agent.tmuxSession = session + agent.State = StateRunning + agent.launchGen = 2 + agent.startupKickInFlight = true + agent.startupKickGen = 2 + m.mu.Unlock() + + if err := m.SendKick("scanner", "go drain live issues"); err != nil { + t.Fatalf("SendKick while startup kick is in flight should defer, got error: %v", err) + } + if agent.LastKick != nil { + t.Fatal("kick was typed immediately instead of being deduped through the startup delivery") + } + + paneInject(t, session, "goose is ready") + requirePaneShows(t, session, "goose is ready") + m.deliverStartupKick(agent, "bootstrap prompt", 2) + + if agent.LastKick == nil { + t.Fatal("deferred kick was not delivered") + } + if agent.LastKickMessage != "go drain live issues" { + t.Fatalf("LastKickMessage = %q, want startup-time SendKick to replace bootstrap", agent.LastKickMessage) + } + if len(agent.KickHistory) != 1 { + t.Fatalf("KickHistory length = %d, want one kick (no bootstrap double-delivery)", len(agent.KickHistory)) + } +} + +// --------------------------------------------------------------------------- +// runCopilotDiagnostic — binary-not-found early return. +// --------------------------------------------------------------------------- + +func TestRunCopilotDiagnostic_NoBinary(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + // Temporarily remove the global copilot stub so backendBinary("copilot") + // fails — without mutating PATH (which would race launch goroutines). + stub := filepath.Join(stubBinDir, "copilot") + if orig, err := os.ReadFile(stub); err == nil { + os.Remove(stub) + t.Cleanup(func() { _ = os.WriteFile(stub, orig, 0o755) }) + } + session := "hive-covdiag" + newRawTmuxSession(t, session) + m := NewManager(map[string]config.AgentConfig{"covdiag": {Backend: "copilot"}}, discardLogger(), ProjectContext{}) + m.mu.RLock() + agent := m.agents["covdiag"] + m.mu.RUnlock() + agent.tmuxSession = session + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + // ensureTmuxSession succeeds (session exists), then copilot binary lookup + // fails -> early return. + m.runCopilotDiagnostic(ctx, agent) +} + +// --------------------------------------------------------------------------- +// NewManager — with a UID map file present on disk. +// --------------------------------------------------------------------------- + +func TestNewManager_ResolveByID(t *testing.T) { + // Write a uid-map to a temp path and point the loader at it by pre-seeding + // the well-known path is not possible (const). Instead exercise LoadUIDMap + // + NewManager wiring by constructing agents whose IDs differ from names. + dir := t.TempDir() + t.Setenv("HIVE_WORK_DIR", dir) + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude", ID: "scanner-id-1"}, + }, discardLogger(), ProjectContext{}) + if got := m.ResolveAgent("scanner-id-1"); got != "scanner" { + t.Errorf("ResolveAgent by id = %q, want scanner", got) + } + if got := m.ResolveAgent("unknown"); got != "unknown" { + t.Errorf("ResolveAgent unknown should echo input, got %q", got) + } +} + +// --------------------------------------------------------------------------- +// tmuxCmd — per-agent socket branch (agent.tmuxSocket set). +// --------------------------------------------------------------------------- + +func TestTmuxCmd_SocketBranch(t *testing.T) { + m := NewManager(map[string]config.AgentConfig{"a": {Backend: "claude"}}, discardLogger(), ProjectContext{}) + m.mu.RLock() + agent := m.agents["a"] + m.mu.RUnlock() + agent.tmuxSocket = "hive-a" + cmd := m.tmuxCmd(agent, "has-session", "-t", "x") + // The built command should route through the -L socket flag. + joined := "" + for _, a := range cmd.Args { + joined += a + " " + } + if !containsBoot(joined, "-L") || !containsBoot(joined, "hive-a") { + t.Errorf("socket tmux cmd should include -L hive-a: %q", joined) + } +} + +func TestTmuxCmd_SuExecBranch(t *testing.T) { + agentName := "definitely-missing-agent-user" + m := NewManager(map[string]config.AgentConfig{agentName: {Backend: "claude"}}, discardLogger(), ProjectContext{}) + m.mu.RLock() + agent := m.agents[agentName] + m.mu.RUnlock() + agent.UID = 2001 + agent.tmuxSocket = "hive-" + agentName + cmd := m.tmuxCmd(agent, "has-session") + if cmd.Args[0] != "su-exec" { + t.Errorf("UID>0 tmux cmd should run via su-exec, got %q", cmd.Args[0]) + } + wantUserSpec := "2001:" + strconv.Itoa(os.Getgid()) + if cmd.Args[1] != wantUserSpec { + t.Errorf("UID>0 tmux cmd should fall back to numeric uid:gid %q, got %q", wantUserSpec, cmd.Args[1]) + } +} + +func TestEnsureTmuxSession_IncludesStderr(t *testing.T) { + dir := t.TempDir() + fake := filepath.Join(dir, "su-exec") + script := `#!/bin/sh +case "$*" in + *has-session*) exit 1 ;; + *new-session*) echo "su-exec: getpwnam(hive-architect): Success" >&2; exit 1 ;; +esac +exit 0 +` + if err := os.WriteFile(fake, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("HIVE_WORK_DIR", filepath.Join(dir, "agents")) + + m := NewManager(map[string]config.AgentConfig{"architect": {Backend: "claude"}}, discardLogger(), ProjectContext{}) + m.mu.Lock() + agent := m.agents["architect"] + agent.UID = 2006 + agent.tmuxSocket = "hive-architect" + agent.tmuxSession = "hive-architect" + err := m.ensureTmuxSession(agent) + m.mu.Unlock() + if err == nil { + t.Fatal("ensureTmuxSession returned nil, want tmux creation error") + } + if got := err.Error(); !strings.Contains(got, "getpwnam") { + t.Fatalf("ensureTmuxSession error %q does not include tmux stderr", got) + } +} + +// --------------------------------------------------------------------------- +// ensureTmuxSession — mkdir failure branch (work dir path blocked by a file). +// --------------------------------------------------------------------------- + +func TestEnsureTmuxSession_MkdirError(t *testing.T) { + dir := t.TempDir() + // Make the work dir a regular file so MkdirAll(workDir/agent) fails. + blocker := filepath.Join(dir, "blocked") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("HIVE_WORK_DIR", blocker) + m := NewManager(map[string]config.AgentConfig{"a": {Backend: "claude"}}, discardLogger(), ProjectContext{}) + m.mu.Lock() + agent := m.agents["a"] + agent.tmuxSession = "hive-a-mkdirfail-xyz" + err := m.ensureTmuxSession(agent) + m.mu.Unlock() + if err == nil { + t.Error("expected mkdir error when work dir is a file") + } +} + +// --------------------------------------------------------------------------- +// confirmMenuOption — navigation path (wanted option not initially selected). +// --------------------------------------------------------------------------- + +func TestConfirmMenuOption_NavigatesToOption(t *testing.T) { + if !tmuxAvailable() { + t.Skip("tmux not available") + } + session := "hive-covnav" + newRawTmuxSession(t, session) + m := NewManager(map[string]config.AgentConfig{"covnav": {Backend: "claude"}}, discardLogger(), ProjectContext{}) + m.mu.RLock() + agent := m.agents["covnav"] + m.mu.RUnlock() + agent.tmuxSession = session + + // Title present, but the selected ❯ line does NOT contain the wanted text, + // so confirmMenuOption presses navKey and re-reads (up to max steps). + testTmuxCommand("send-keys", "-t", session, "-l", ": Detected a custom API key").Run() + testTmuxCommand("send-keys", "-t", session, "Enter").Run() + testTmuxCommand("send-keys", "-t", session, "-l", "❯ 1. No (recommended)").Run() + testTmuxCommand("send-keys", "-t", session, "Enter").Run() + time.Sleep(300 * time.Millisecond) + + // Wanted "Yes" is never on the selected line -> exhausts nav steps -> false. + // (Exercises the navigation loop + the "not reached" warning branch.) + _ = m.confirmMenuOption(agent, "Detected a custom API key", "Yes", "Up") +} + +// --------------------------------------------------------------------------- +// seedJSONFile — marshal path already covered; add write-target unwritable +// (directory as target) to hit the write-error warn branch. +// --------------------------------------------------------------------------- + +func TestSeedJSONFile_WriteError(t *testing.T) { + m := NewManager(nil, discardLogger(), ProjectContext{}) + dir := t.TempDir() + // Target path is a directory -> WriteFile fails -> warn branch. + target := filepath.Join(dir, "adir") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + m.seedJSONFile("agent", target, map[string]any{"k": "v"}) +} + +// --------------------------------------------------------------------------- +// mergeApprovedAPIKeys — already-present seed (no change branch) + write path. +// --------------------------------------------------------------------------- + +func TestMergeApprovedAPIKeys_AlreadyPresent(t *testing.T) { + m := NewManager(nil, discardLogger(), ProjectContext{}) + dir := t.TempDir() + path := filepath.Join(dir, "claude.json") + + // Seed the file already containing every approved key form for "scanner". + seed := inferenceUserConfigSeed("scanner") + data, _ := json.Marshal(map[string]any{ + "customApiKeyResponses": seed["customApiKeyResponses"], + }) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + // No missing keys -> changed=false -> early return (no rewrite). + m.mergeApprovedAPIKeys("scanner", path) +} diff --git a/agent/busy_visibility.go b/agent/busy_visibility.go new file mode 100644 index 0000000..3d6eb19 --- /dev/null +++ b/agent/busy_visibility.go @@ -0,0 +1,541 @@ +package agent + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/hivecommons/crux" +) + +const ( + busyNoActivityCondition = "busy-no-activity" + busyOverCeilingCondition = "busy-over-ceiling" + copilotQuestionFormCondition = "copilot-question-form" + busyUndeliverableThreshold = 3 + transcriptLivenessScanInterval = 10 * time.Second + // transcriptHotSessionAge bounds which Copilot sessions the refresher + // re-stats every scan: a session whose events.jsonl changed within this + // window may still be live. Older sessions are ended CLI runs whose + // cached mtime is trusted until the next full rescan, so a home that + // has accumulated thousands of sessions costs a handful of stats per + // tick rather than one NFS GETATTR per session (hivecommons/hive: the + // heartbeat collect spent 8–12 s in this walk on a 16k-session hive). + transcriptHotSessionAge = 6 * time.Hour + // transcriptFullRescanInterval is how often every session, cold ones + // included, is re-stat'd, so a resumed old session is noticed well + // inside the busy-no-activity threshold (60m by default). + transcriptFullRescanInterval = 10 * time.Minute + // busyNoActivityRecoveryKicks and busyNoActivityRecoveryFactor bound when + // a busy-no-activity pane stops being visibility-only and is restarted + // (#12505): twice the kicks that raise the condition, and twice the + // configured silence threshold (60m by default). A live turn writes + // transcript events, so only a wedged CLI clears both bars. + busyNoActivityRecoveryKicks = 2 * busyUndeliverableThreshold + busyNoActivityRecoveryFactor = 2 +) + +const copilotQuestionFormMessage = "Copilot CLI asked the user a question; no human is attached — dismissed with Escape so the kick can be delivered" + +type transcriptActivityScan struct { + name string + uid int +} + +type transcriptActivityResult struct { + name string + at time.Time + ok bool +} + +// transcriptSessionCache remembers, per agent, the events.jsonl mtime last +// observed for each session directory (zero when the directory has no +// events.jsonl) and when every session was last re-stat'd. Owned by the +// refresher goroutine; never shared. +type transcriptSessionCache struct { + sessions map[string]time.Time + lastFull time.Time +} + +// newestCopilotTranscriptActivity reports the newest events.jsonl mtime under +// the agent's own Copilot session-state tree. Sub-agents write separate +// session directories under the same HOME, so scanning every session catches +// their activity without parsing JSON. Every session is stat'd; the refresher +// uses newestCopilotTranscriptActivityCached to skip cold ones. +func newestCopilotTranscriptActivity(home string) (time.Time, bool) { + return newestCopilotTranscriptActivityCached(home, nil, time.Time{}) +} + +// newestCopilotTranscriptActivityCached is newestCopilotTranscriptActivity +// with a per-agent cache: between full rescans, a session whose cached mtime +// is older than transcriptHotSessionAge is trusted rather than stat'd. A +// session with no events.jsonl yet (zero cached mtime) is always re-stat'd: +// the CLI creates the directory a moment before the first event lands, and +// trusting that empty read would hide a brand-new session until the next +// full rescan. Sessions that disappeared from the directory are dropped from +// the cache. A nil cache stats everything. +func newestCopilotTranscriptActivityCached(home string, cache *transcriptSessionCache, now time.Time) (time.Time, bool) { + root := filepath.Join(home, ".copilot", "session-state") + entries, err := os.ReadDir(root) + if err != nil { + return time.Time{}, false + } + full := cache == nil || cache.lastFull.IsZero() || now.Sub(cache.lastFull) >= transcriptFullRescanInterval + var seen map[string]bool + if cache != nil { + if cache.sessions == nil { + cache.sessions = make(map[string]time.Time, len(entries)) + } + seen = make(map[string]bool, len(entries)) + } + var newest time.Time + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + if cache != nil { + seen[name] = true + if prev, ok := cache.sessions[name]; ok && !full && !prev.IsZero() && now.Sub(prev) > transcriptHotSessionAge { + if prev.After(newest) { + newest = prev + } + continue + } + } + var mod time.Time + if info, err := os.Stat(filepath.Join(root, name, "events.jsonl")); err == nil { + mod = info.ModTime() + } + if cache != nil { + cache.sessions[name] = mod + } + if mod.After(newest) { + newest = mod + } + } + if cache != nil { + for name := range cache.sessions { + if !seen[name] { + delete(cache.sessions, name) + } + } + if full { + cache.lastFull = now + } + } + if newest.IsZero() { + return time.Time{}, false + } + return newest, true +} + +// markKickUndeliverableLocked records that a kick attempt found the pane busy +// or otherwise unsafe for input. reason says why delivery failed and is carried +// in the self-report. Caller holds m.mu. +func (m *Manager) markKickUndeliverableLocked(agent *AgentProcess, now time.Time, reason string) { + if agent.KicksUndeliverable == 0 { + agent.BusySince = now + } + agent.KicksUndeliverable++ + m.refreshBusyVisibilityLocked(agent, now) + if agent.BusyCondition != "" { + reason = agent.BusyCondition + } + if reason == "" { + reason = "unknown" + } + // A pane that is busy because the agent is mid-turn is not a stall + // (#12268): the governor re-kicks an agent whose previous turn is still + // streaming, each attempt waits out the prompt timeout, and three of those + // inside one long review turn used to file an upstream issue that cleared + // itself the moment the turn ended. Only self-report once the agent's own + // transcript has been silent for the busy-no-activity threshold, the same + // bar busyCondition applies before it calls the pane stuck. + silentFor, live := kickUndeliverableTranscriptLiveness(agent, now) + if live { + if m.logger != nil { + m.logger.Info("kick undeliverable while agent turn in progress; not self-reporting", + "agent", agent.Name, + "reason", reason, + "kicks_undeliverable", agent.KicksUndeliverable, + "transcript_silent_for", roundDurationForHumans(silentFor)) + } + return + } else if silentFor > 0 { + reason = fmt.Sprintf("%s; transcript silent for %s", reason, roundDurationForHumans(silentFor)) + } + // Decision B on #12787: a stall that busyNoActivityStall is about to + // restart is not yet a defect worth a fleet issue — most self-recover. + // Until that restart has been attempted the condition stays a dashboard + // warning (BusyCondition) and a log line; only kicks that keep failing + // after the restart are self-reported. + if agent.busyNoActivityRestartAt.IsZero() { + if m.logger != nil { + m.logger.Warn("kick undeliverable; busy-no-activity restart not yet attempted, not self-reporting", + "agent", agent.Name, + "reason", reason, + "kicks_undeliverable", agent.KicksUndeliverable, + "transcript_silent_for", roundDurationForHumans(silentFor)) + } + return + } + // Self-report (#11868): the tracker applies the ≥3-consecutive threshold. + m.observeCondition(crux.ClassKickFailedRepeatedly, agent.Name, + busyNoActivityRestartFailedMessage(agent, now, reason), "") +} + +// busyNoActivityRestartFailedMessage is the kick_failed_repeatedly self-report +// text once the stall restart has been attempted and kicks still fail. It +// carries the restart time and count so the fleet issue says the recovery +// already ran and did not help, not merely that kicks are failing. +func busyNoActivityRestartFailedMessage(agent *AgentProcess, now time.Time, reason string) string { + restartAgo := now.Sub(agent.busyNoActivityRestartAt) + if restartAgo < 0 { + restartAgo = 0 + } + return fmt.Sprintf("kick undeliverable %d times in a row after busy-no-activity restart did not recover the session (%s; restarted %s ago at %s, %d restart(s) this episode)", + agent.KicksUndeliverable, reason, roundDurationForHumans(restartAgo), + agent.busyNoActivityRestartAt.UTC().Format(time.RFC3339), agent.busyNoActivityRestarts) +} + +// markBusyNoActivityRestartLocked records that busyNoActivityStall restarted +// the agent at now. Caller holds m.mu. +func (m *Manager) markBusyNoActivityRestartLocked(agent *AgentProcess, now time.Time) { + agent.busyNoActivityRestartAt = now + agent.busyNoActivityRestarts++ +} + +// reportBusyNoActivityRestartFailed self-reports a stall restart that itself +// errored: the recovery never ran, so there is nothing left to wait for. +func (m *Manager) reportBusyNoActivityRestartFailed(name, reason string, err error) { + m.mu.RLock() + agent, ok := m.agents[name] + var kicks, restarts int + if ok { + kicks, restarts = agent.KicksUndeliverable, agent.busyNoActivityRestarts + } + m.mu.RUnlock() + if !ok { + return + } + m.observeCondition(crux.ClassKickFailedRepeatedly, name, + fmt.Sprintf("kick undeliverable %d times in a row and busy-no-activity restart failed: %v (%s; %d restart(s) this episode)", + kicks, err, reason, restarts), "") +} + +// kickUndeliverableTranscriptLiveness reports how long the agent's transcript +// has been silent and whether that is short enough to call the busy pane a +// turn in progress. Agents without a transcript cache (non-Copilot backends, +// or a scan that has not run yet) report (0, false): the caller cannot tell a +// live turn from a stall, so it keeps self-reporting as before. +func kickUndeliverableTranscriptLiveness(agent *AgentProcess, now time.Time) (time.Duration, bool) { + last := agent.LastTranscriptActivity + if last.IsZero() { + return 0, false + } + silentFor := now.Sub(last) + if silentFor < 0 { + silentFor = 0 + } + return silentFor, silentFor < agent.Config.EffectiveBusyNoActivityThreshold() +} + +// markCopilotQuestionFormLocked surfaces a question-form dismissal through the +// same dashboard busy-condition fields as other no-deliverable-kick states. +// Caller holds m.mu. +func (m *Manager) markCopilotQuestionFormLocked(agent *AgentProcess, now time.Time) { + if agent.KicksUndeliverable == 0 { + agent.BusySince = now + } + agent.KicksUndeliverable++ + agent.BusyCondition = copilotQuestionFormCondition + agent.BusyConditionMessage = copilotQuestionFormMessage + agent.busyConditionWarned = copilotQuestionFormCondition +} + +// resetBusyVisibilityLocked clears the busy-delivery episode because a kick +// was delivered (or cancelled): the session is active again, so any +// kick_failed_repeatedly condition is cleared and the stall-restart marker +// dropped. Caller holds m.mu. +func (m *Manager) resetBusyVisibilityLocked(agent *AgentProcess) { + if agent.KicksUndeliverable > 0 || !agent.busyNoActivityRestartAt.IsZero() { + m.clearCondition(crux.ClassKickFailedRepeatedly, agent.Name) + } + agent.busyNoActivityRestartAt = time.Time{} + agent.busyNoActivityRestarts = 0 + m.resetBusyCountersLocked(agent) +} + +// resetBusyVisibilityForRestartLocked is the restart teardown's variant: the +// counters start over for the new session, but the stall-restart marker and +// any open self-report stay until a kick is actually delivered — a restart is +// the recovery attempt, not proof that it worked. Caller holds m.mu. +func (m *Manager) resetBusyVisibilityForRestartLocked(agent *AgentProcess) { + m.resetBusyCountersLocked(agent) +} + +func (m *Manager) resetBusyCountersLocked(agent *AgentProcess) { + agent.KicksUndeliverable = 0 + agent.BusySince = time.Time{} + agent.BusyCondition = "" + agent.BusyConditionMessage = "" + agent.busyConditionWarned = "" +} + +// refreshBusyVisibilityLocked derives the visibility-only busy condition from +// cached transcript liveness. Caller holds m.mu. +func (m *Manager) refreshBusyVisibilityLocked(agent *AgentProcess, now time.Time) { + condition, message := busyCondition(agent, now) + agent.BusyCondition = condition + agent.BusyConditionMessage = message + if condition == "" { + agent.busyConditionWarned = "" + return + } + if agent.busyConditionWarned == condition { + return + } + agent.busyConditionWarned = condition + if m.logger == nil { + return + } + m.logger.Warn("agent busy with no deliverable kick visibility condition", + "agent", agent.Name, + "condition", condition, + "message", message, + "kicks_undeliverable", agent.KicksUndeliverable, + "busy_since", agent.BusySince.UTC().Format(time.RFC3339), + "last_transcript_activity", formatTimeForLog(agent.LastTranscriptActivity)) +} + +// pendingTranscriptActivityScansLocked returns the Copilot agents whose +// transcript cache is stale. Caller holds m.mu; filesystem scans happen after +// releasing it. +func (m *Manager) pendingTranscriptActivityScansLocked(now time.Time) []transcriptActivityScan { + scans := make([]transcriptActivityScan, 0, len(m.agents)) + for _, agent := range m.agents { + if effectiveBackend(agent) != "copilot" { + continue + } + if !agent.lastTranscriptScanAt.IsZero() && now.Sub(agent.lastTranscriptScanAt) < transcriptLivenessScanInterval { + continue + } + scans = append(scans, transcriptActivityScan{name: agent.Name, uid: agent.UID}) + } + return scans +} + +// scanTranscriptActivity stats each agent's sessions. caches is keyed by +// agent name and may be nil (every session is stat'd). +func scanTranscriptActivity(scans []transcriptActivityScan, caches map[string]*transcriptSessionCache, now time.Time) []transcriptActivityResult { + results := make([]transcriptActivityResult, 0, len(scans)) + for _, scan := range scans { + var cache *transcriptSessionCache + if caches != nil { + cache = caches[scan.name] + if cache == nil { + cache = &transcriptSessionCache{} + caches[scan.name] = cache + } + } + at, ok := newestCopilotTranscriptActivityCached(AgentHome(scan.name, scan.uid, "copilot"), cache, now) + results = append(results, transcriptActivityResult{name: scan.name, at: at, ok: ok}) + } + return results +} + +// applyTranscriptActivityResultsLocked updates the transcript cache after an +// out-of-lock scan. Caller holds m.mu. +func (m *Manager) applyTranscriptActivityResultsLocked(results []transcriptActivityResult, scannedAt time.Time) { + for _, result := range results { + agent, ok := m.agents[result.name] + if !ok { + continue + } + agent.lastTranscriptScanAt = scannedAt + if result.ok { + agent.LastTranscriptActivity = result.at + } + } +} + +// StartTranscriptActivityRefresh keeps every Copilot agent's +// LastTranscriptActivity fresh on its own goroutine, so readers such as +// AllStatuses (the dashboard status build and the hub heartbeat collect) +// only ever copy a cached value and never wait on the session-state walk. +// That walk stats one events.jsonl per session directory; on an NFS /data +// with thousands of accumulated sessions a cold walk takes 8–12 s, which +// blew the heartbeat's 10 s collect budget on every beat and left the hub +// reading the hive as unhealthy. Runs until ctx is cancelled. +func (m *Manager) StartTranscriptActivityRefresh(ctx context.Context) { + ticker := time.NewTicker(transcriptLivenessScanInterval) + defer ticker.Stop() + m.runTranscriptActivityRefresh(ctx, ticker.C, time.Now) +} + +// runTranscriptActivityRefresh is StartTranscriptActivityRefresh with the +// tick source and clock injected. It scans once immediately, then once per +// tick; scans never overlap because they run on this goroutine alone. +func (m *Manager) runTranscriptActivityRefresh(ctx context.Context, ticks <-chan time.Time, now func() time.Time) { + caches := map[string]*transcriptSessionCache{} + slow := false + m.refreshTranscriptActivity(caches, now, &slow) + for { + select { + case <-ctx.Done(): + return + case <-ticks: + m.refreshTranscriptActivity(caches, now, &slow) + } + } +} + +// refreshTranscriptActivity runs one scan pass: pick the stale agents under +// a short read lock, stat their sessions with no lock held, then publish +// the results under a short write lock. Returns how many agents were +// scanned. slow tracks the previous pass's verdict so a walk that outlives +// the scan interval is logged on the transition, not every tick. +func (m *Manager) refreshTranscriptActivity(caches map[string]*transcriptSessionCache, now func() time.Time, slow *bool) int { + started := now() + m.mu.RLock() + scans := m.pendingTranscriptActivityScansLocked(started) + m.mu.RUnlock() + if len(scans) == 0 { + return 0 + } + results := scanTranscriptActivity(scans, caches, started) + m.mu.Lock() + m.applyTranscriptActivityResultsLocked(results, started) + live := make(map[string]bool, len(m.agents)) + for name := range m.agents { + live[name] = true + } + m.mu.Unlock() + for name := range caches { + if !live[name] { + delete(caches, name) + } + } + if took := now().Sub(started); took > transcriptLivenessScanInterval { + if slow != nil && !*slow && m.logger != nil { + m.logger.Warn("copilot transcript activity scan outlived its interval; agent liveness reads may lag", + "took", took.Round(time.Millisecond).String(), "agents", len(scans), "interval", transcriptLivenessScanInterval.String()) + } + if slow != nil { + *slow = true + } + } else if slow != nil { + *slow = false + } + return len(results) +} + +func busyCondition(agent *AgentProcess, now time.Time) (string, string) { + threshold := agent.Config.EffectiveBusyNoActivityThreshold() + lastActivity := agent.LastTranscriptActivity + if lastActivity.IsZero() { + lastActivity = agent.BusySince + } + silentFor := time.Duration(0) + if !lastActivity.IsZero() { + silentFor = now.Sub(lastActivity) + } + + if ceiling := agent.Config.MaxTurnDuration; ceiling > 0 { + start := agent.BusySince + if !start.IsZero() && now.Sub(start) >= ceiling { + return busyOverCeilingCondition, fmt.Sprintf( + "pane shows Working for %s, exceeding max_turn_duration %s; %d kicks undeliverable", + roundDurationForHumans(now.Sub(start)), roundDurationForHumans(ceiling), agent.KicksUndeliverable) + } + } + + if agent.KicksUndeliverable >= busyUndeliverableThreshold && !lastActivity.IsZero() && silentFor >= threshold { + since := "" + if !agent.BusySince.IsZero() { + since = agent.BusySince.UTC().Format("15:04Z") + } + return busyNoActivityCondition, fmt.Sprintf( + "pane shows Working but no transcript activity for %s; %d kicks undeliverable since %s", + roundDurationForHumans(silentFor), agent.KicksUndeliverable, since) + } + + return "", "" +} + +// busyNoActivityStall reports why a pane that still shows Working or, for a +// live CLI, no input prompt, refuses every kick, and has produced no transcript +// activity for far longer than the busy-no-activity threshold should be +// restarted, or "" when it should not. +// Without this the condition was visibility-only: a wedged CLI refused kicks +// indefinitely and nothing broke the loop (#12505). Restarts share the +// session-liveness cooldown so a pane that wedges again is not churned. +// +// A live CLI pane that shows neither Working nor any input prompt is wedged +// the same way: every kick times out with "CLI did not reach input prompt" +// (#12916), and since #12860 only a restart attempt lets that self-report, so +// without this branch such a pane was neither recovered nor reported. A bare +// shell (no CLI marker) is left to crash recovery. +// +// A consent/blocking-prompt screen renders a "❯"-selected menu line, so it +// satisfies paneShowsInputPrompt, yet waitForInputPromptForAgentUnless never +// accepts it and every kick fails the same way (#12921). It is checked before +// the input-prompt test so it is not masked; only inference backends have a +// consent-dismissal path, so on every other backend it restarts here. +// Caller holds m.mu (read or write). +func busyNoActivityStall(agent *AgentProcess, pane string, now time.Time) string { + if agent.KicksUndeliverable < busyNoActivityRecoveryKicks { + return "" + } + paneState := "pane shows Working" + if !paneShowsAgentWorking(pane) && !paneShowsActiveWork(pane) { + switch { + case !paneHasCLIMarker(pane): + return "" + case paneShowsConsentScreen(pane): + // Inference backends re-run prompt dismissal for this instead + // (dismissConsentIfStuck); a restart would loop to the same screen. + if IsInferenceBackend(effectiveBackend(agent)) { + return "" + } + paneState = "pane shows a consent or blocking prompt" + case paneShowsInputPrompt(pane): + return "" + default: + paneState = "pane shows no input prompt" + } + } + if now.Sub(agent.lastSessionStallRestart) < sessionStallRestartCooldown { + return "" + } + lastActivity := agent.LastTranscriptActivity + if lastActivity.IsZero() { + lastActivity = agent.BusySince + } + if lastActivity.IsZero() { + return "" + } + silentFor := now.Sub(lastActivity) + if silentFor < busyNoActivityRecoveryFactor*agent.Config.EffectiveBusyNoActivityThreshold() { + return "" + } + return fmt.Sprintf("%s: %s with no transcript activity for %s; %d kicks undeliverable", + busyNoActivityCondition, paneState, roundDurationForHumans(silentFor), agent.KicksUndeliverable) +} + +func roundDurationForHumans(d time.Duration) string { + if d < time.Minute { + return d.Round(time.Second).String() + } + return d.Round(time.Minute).String() +} + +func formatTimeForLog(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} diff --git a/agent/busy_visibility_test.go b/agent/busy_visibility_test.go new file mode 100644 index 0000000..f77f404 --- /dev/null +++ b/agent/busy_visibility_test.go @@ -0,0 +1,550 @@ +package agent + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/hivecommons/crux" + "github.com/hivecommons/crux/config" +) + +func busyVisibilityTestManager() *Manager { + return NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude"}, + }, slog.New(slog.NewTextHandler(io.Discard, nil)), ProjectContext{}) +} + +func TestBusyVisibilityCounterResetsOnDeliveryAndRestart(t *testing.T) { + m := busyVisibilityTestManager() + agent := m.agents["scanner"] + now := time.Date(2026, 9, 29, 4, 16, 0, 0, time.UTC) + + m.markKickUndeliverableLocked(agent, now, "pane busy") + m.markKickUndeliverableLocked(agent, now.Add(time.Minute), "pane busy") + if agent.KicksUndeliverable != 2 { + t.Fatalf("kicks undeliverable = %d, want 2", agent.KicksUndeliverable) + } + if !agent.BusySince.Equal(now) { + t.Fatalf("busy since = %v, want %v", agent.BusySince, now) + } + + m.resetBusyVisibilityLocked(agent) + if agent.KicksUndeliverable != 0 || !agent.BusySince.IsZero() || agent.BusyCondition != "" { + t.Fatalf("reset after delivery left busy state: %+v", agent) + } + + m.markKickUndeliverableLocked(agent, now, "pane busy") + m.invalidateKicksOnRestartLocked(agent, "test restart", false, false) + if agent.KicksUndeliverable != 0 || !agent.BusySince.IsZero() || agent.BusyCondition != "" { + t.Fatalf("restart reset left busy state: %+v", agent) + } +} + +func TestNewestCopilotTranscriptActivityScansMultipleSessions(t *testing.T) { + home := t.TempDir() + older := time.Date(2026, 9, 29, 2, 0, 0, 0, time.UTC) + newer := older.Add(2 * time.Hour) + writeEventsFile(t, home, "parent", older) + writeEventsFile(t, home, "sub-agent", newer) + if err := os.MkdirAll(filepath.Join(home, ".copilot", "session-state", "empty"), 0o755); err != nil { + t.Fatal(err) + } + + got, ok := newestCopilotTranscriptActivity(home) + if !ok { + t.Fatal("newestCopilotTranscriptActivity ok = false, want true") + } + if !got.Equal(newer) { + t.Fatalf("newest transcript activity = %v, want %v", got, newer) + } +} + +func TestBusyConditionThresholdsAndDefaults(t *testing.T) { + now := time.Date(2026, 9, 29, 5, 0, 0, 0, time.UTC) + busySince := now.Add(-45 * time.Minute) + lastActivity := now.Add(-31 * time.Minute) + agent := &AgentProcess{ + Config: config.AgentConfig{}, + KicksUndeliverable: busyUndeliverableThreshold, + BusySince: busySince, + LastTranscriptActivity: lastActivity, + } + if got := agent.Config.EffectiveBusyNoActivityThreshold(); got != config.DefaultBusyNoActivityThreshold { + t.Fatalf("default threshold = %v, want %v", got, config.DefaultBusyNoActivityThreshold) + } + condition, message := busyCondition(agent, now) + if condition != busyNoActivityCondition { + t.Fatalf("condition = %q (%s), want %q", condition, message, busyNoActivityCondition) + } + + agent.KicksUndeliverable = busyUndeliverableThreshold - 1 + condition, _ = busyCondition(agent, now) + if condition != "" { + t.Fatalf("below kick threshold condition = %q, want empty", condition) + } + + agent.KicksUndeliverable = busyUndeliverableThreshold + agent.Config.BusyNoActivityThreshold = time.Hour + condition, _ = busyCondition(agent, now) + if condition != "" { + t.Fatalf("below silence threshold condition = %q, want empty", condition) + } + + agent.Config.MaxTurnDuration = 40 * time.Minute + condition, message = busyCondition(agent, now) + if condition != busyOverCeilingCondition { + t.Fatalf("ceiling condition = %q (%s), want %q", condition, message, busyOverCeilingCondition) + } + + oldKick := now.Add(-2 * time.Hour) + idle := &AgentProcess{ + Config: config.AgentConfig{MaxTurnDuration: 40 * time.Minute}, + LastKick: &oldKick, + } + condition, _ = busyCondition(idle, now) + if condition != "" { + t.Fatalf("idle old kick condition = %q, want empty", condition) + } +} + +func writeEventsFile(t *testing.T, home, id string, mod time.Time) { + t.Helper() + dir := filepath.Join(home, ".copilot", "session-state", id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "events.jsonl") + if err := os.WriteFile(path, []byte(`{"type":"assistant.message"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, mod, mod); err != nil { + t.Fatal(err) + } +} + +// kickFailedEvidence is what the runtime has told its ConditionReporter +// about kick_failed_repeatedly and not cleared since. +func kickFailedEvidence(rec *conditionRecorder) []conditionObservation { + return rec.active(crux.ClassKickFailedRepeatedly) +} + +// Decision B on #12787: kicks that fail before busyNoActivityStall has had +// its turn are a dashboard warning, not a fleet issue. 8 undeliverable kicks +// over a 30m-silent transcript (the exact shape of the #12787 report) must +// not self-report. +func TestKickUndeliverableBeforeStallRestartIsNotSelfReported(t *testing.T) { + clock := time.Date(2026, 10, 17, 16, 25, 0, 0, time.UTC) + m := busyVisibilityTestManager() + tr := recordConditions(t, m) + agent := m.agents["scanner"] + agent.LastTranscriptActivity = clock.Add(-config.DefaultBusyNoActivityThreshold - time.Minute) + for i := 0; i < 8; i++ { + m.markKickUndeliverableLocked(agent, clock, "pane not at a ready input prompt") + } + if agent.KicksUndeliverable != 8 { + t.Fatalf("kicks undeliverable = %d, want 8", agent.KicksUndeliverable) + } + if agent.BusyCondition != busyNoActivityCondition { + t.Fatalf("busy condition = %q, want %q to stay visible on the dashboard", agent.BusyCondition, busyNoActivityCondition) + } + if evs := kickFailedEvidence(tr); len(evs) != 0 { + t.Fatalf("self-reported before the stall restart was attempted: %q", evs[0].Message) + } + + // Non-Copilot backends have no transcript cache; they too wait for the + // restart instead of reporting on the third kick. + agent.LastTranscriptActivity = time.Time{} + for i := 0; i < 3; i++ { + m.markKickUndeliverableLocked(agent, clock, "CLI did not reach input prompt") + } + if evs := kickFailedEvidence(tr); len(evs) != 0 { + t.Fatalf("no-transcript agent self-reported before the stall restart: %q", evs[0].Message) + } +} + +func TestKickUndeliverableDuringLiveTurnIsNotSelfReported(t *testing.T) { + clock := time.Date(2026, 10, 16, 3, 26, 26, 0, time.UTC) + m := busyVisibilityTestManager() + tr := recordConditions(t, m) + agent := m.agents["scanner"] + // Even after a stall restart, a transcript written to seconds ago means + // the pane is busy, not stuck (#12268). + m.markBusyNoActivityRestartLocked(agent, clock.Add(-5*time.Minute)) + agent.LastTranscriptActivity = clock.Add(-20 * time.Second) + for i := 0; i < 3; i++ { + m.markKickUndeliverableLocked(agent, clock, "CLI did not reach input prompt") + } + if agent.KicksUndeliverable != 3 { + t.Fatalf("kicks undeliverable = %d, want 3 (dashboard counter still tracks the episode)", agent.KicksUndeliverable) + } + if evs := kickFailedEvidence(tr); len(evs) != 0 { + t.Fatalf("live turn produced kick_failed_repeatedly evidence: %q", evs[0].Message) + } +} + +// Scenario (b): the stall restart ran and the session came back — the next +// delivered kick clears the marker and the tracker, and nothing is filed. +func TestStallRestartThatRecoversClearsWithoutSelfReport(t *testing.T) { + clock := time.Date(2026, 10, 17, 17, 0, 0, 0, time.UTC) + m := busyVisibilityTestManager() + tr := recordConditions(t, m) + agent := m.agents["scanner"] + agent.LastTranscriptActivity = clock.Add(-65 * time.Minute) + for i := 0; i < busyNoActivityRecoveryKicks; i++ { + m.markKickUndeliverableLocked(agent, clock, "pane not at a ready input prompt") + } + + // The restart loop marks the attempt, then the teardown resets counters. + m.markBusyNoActivityRestartLocked(agent, clock) + m.invalidateKicksOnRestartLocked(agent, "stalled session recovery", false, false) + if agent.KicksUndeliverable != 0 || agent.BusyCondition != "" { + t.Fatalf("restart reset left busy state: %+v", agent) + } + if agent.busyNoActivityRestartAt.IsZero() || agent.busyNoActivityRestarts != 1 { + t.Fatalf("restart reset dropped the stall-restart marker: at=%v n=%d", agent.busyNoActivityRestartAt, agent.busyNoActivityRestarts) + } + + // One more undeliverable kick while the new CLI boots is observed (the + // host applies its streak before anything is filed), then the kick lands + // and the recovery clears it. + m.markKickUndeliverableLocked(agent, clock.Add(time.Minute), "pane not at a ready input prompt") + if obs := tr.observations(crux.ClassKickFailedRepeatedly); len(obs) != 1 { + t.Fatalf("post-restart kick failure observations = %d, want 1", len(obs)) + } + m.recordDeliveredKickLocked(agent, "go", "governor") + if !agent.busyNoActivityRestartAt.IsZero() || agent.busyNoActivityRestarts != 0 { + t.Fatalf("delivered kick did not clear the stall-restart marker: %+v", agent) + } + if evs := kickFailedEvidence(tr); len(evs) != 0 { + t.Fatalf("recovered stall left kick_failed_repeatedly open: %q", evs[0].Message) + } + if cleared := tr.clears(crux.ClassKickFailedRepeatedly); len(cleared) != 1 { + t.Fatalf("recovered stall produced %d cleared conditions, want 1", len(cleared)) + } +} + +// Scenario (c): the stall restart ran and kicks keep failing afterwards — +// exactly one kick_failed_repeatedly report, saying the restart did not help. +func TestStallRestartThatDoesNotRecoverSelfReportsOnce(t *testing.T) { + clock := time.Date(2026, 10, 17, 17, 0, 0, 0, time.UTC) + m := busyVisibilityTestManager() + tr := recordConditions(t, m) + agent := m.agents["scanner"] + agent.LastTranscriptActivity = clock.Add(-65 * time.Minute) + for i := 0; i < busyNoActivityRecoveryKicks; i++ { + m.markKickUndeliverableLocked(agent, clock, "pane not at a ready input prompt") + } + restartAt := clock + m.markBusyNoActivityRestartLocked(agent, restartAt) + m.invalidateKicksOnRestartLocked(agent, "stalled session recovery", false, false) + + // The restarted CLI never writes a transcript and refuses every kick. + clock = clock.Add(12 * time.Minute) + for i := 0; i < 4; i++ { + m.markKickUndeliverableLocked(agent, clock, "pane not at a ready input prompt") + } + // Every failing kick after the restart is observed; the host's streak + // turns them into one report. + evs := kickFailedEvidence(tr) + if len(evs) != 4 { + t.Fatalf("kick_failed_repeatedly observations = %d, want 4", len(evs)) + } + msg := evs[len(evs)-1].Message + for _, want := range []string{ + "after busy-no-activity restart did not recover the session", + "kick undeliverable 4 times in a row", + "transcript silent for 1h17m", + "restarted 12m0s ago at " + restartAt.Format(time.RFC3339), + "1 restart(s) this episode", + } { + if !strings.Contains(msg, want) { + t.Fatalf("message = %q, want it to contain %q", msg, want) + } + } + + // Recovery later clears it through the normal delivered-kick path. + m.recordDeliveredKickLocked(agent, "go", "governor") + if evs := kickFailedEvidence(tr); len(evs) != 0 { + t.Fatalf("evidence still present after recovery: %q", evs[0].Message) + } + if cleared := tr.clears(crux.ClassKickFailedRepeatedly); len(cleared) != 1 || cleared[0].Key != "scanner" { + t.Fatalf("cleared conditions = %+v, want one kick_failed_repeatedly clear for scanner", cleared) + } +} + +// A stall restart that itself errors is reported without waiting for more +// kicks to pile up on a session that was never restarted. +func TestStallRestartErrorIsSelfReported(t *testing.T) { + clock := time.Date(2026, 10, 17, 17, 0, 0, 0, time.UTC) + m := busyVisibilityTestManager() + tr := recordConditions(t, m) + agent := m.agents["scanner"] + agent.KicksUndeliverable = busyNoActivityRecoveryKicks + m.markBusyNoActivityRestartLocked(agent, clock) + reason := busyNoActivityCondition + ": pane shows Working with no transcript activity for 1h5m; 6 kicks undeliverable" + m.reportBusyNoActivityRestartFailed("scanner", reason, errors.New("tmux kill-session: no server running")) + // The restart error is observed at once; two more failing kicks follow + // it so the host's streak sees all three. + if evs := kickFailedEvidence(tr); len(evs) != 1 { + t.Fatalf("restart error observations = %d, want 1", len(evs)) + } + for i := 0; i < 2; i++ { + m.markKickUndeliverableLocked(agent, clock, "pane not at a ready input prompt") + } + evs := kickFailedEvidence(tr) + if len(evs) != 3 { + t.Fatalf("kick_failed_repeatedly observations = %d, want 3 (restart error + 2 kicks)", len(evs)) + } + if !strings.Contains(evs[2].Message, "after busy-no-activity restart did not recover the session") { + t.Fatalf("message = %q, want the post-restart wording", evs[2].Message) + } + m.reportBusyNoActivityRestartFailed("no-such-agent", reason, errors.New("x")) +} + +func TestBusyNoActivityStallEscalatesOnlyPastRecoveryBars(t *testing.T) { + now := time.Date(2026, 10, 16, 18, 52, 0, 0, time.UTC) + working := "◉ Working (12m 3s · esc interrupt)" + stuck := func() *AgentProcess { + return &AgentProcess{ + Name: "quality", + KicksUndeliverable: 14, + BusySince: now.Add(-65 * time.Minute), + LastTranscriptActivity: now.Add(-95 * time.Minute), + } + } + + reason := busyNoActivityStall(stuck(), working, now) + if !strings.Contains(reason, busyNoActivityCondition) || !strings.Contains(reason, "14 kicks undeliverable") { + t.Fatalf("wedged pane reason = %q, want busy-no-activity restart reason", reason) + } + + // No transcript cache (non-Copilot backends): silence is measured from + // the first undeliverable kick. + noCache := stuck() + noCache.LastTranscriptActivity = time.Time{} + noCache.BusySince = now.Add(-61 * time.Minute) + if got := busyNoActivityStall(noCache, working, now); got == "" { + t.Fatal("no-transcript wedged pane did not escalate") + } + + // A live CLI stuck off its input prompt (#12916): every kick times out + // with "CLI did not reach input prompt" yet the pane never shows Working. + offPrompt := "GitHub Copilot v1.0.88\nLoading environment..." + if got := busyNoActivityStall(stuck(), offPrompt, now); !strings.Contains(got, "pane shows no input prompt") { + t.Fatalf("off-prompt wedged pane reason = %q, want no-input-prompt restart reason", got) + } + + // A live CLI parked on a consent/blocking menu (#12921): the "❯"-selected + // option satisfies paneShowsInputPrompt, but no kick is ever delivered. + consent := "Bypass Permissions mode\n❯ 1. No, exit\n 2. Yes, I accept\nEnter to confirm" + if got := busyNoActivityStall(stuck(), consent, now); !strings.Contains(got, "pane shows a consent or blocking prompt") { + t.Fatalf("consent-screen wedged pane reason = %q, want consent/blocking-prompt restart reason", got) + } + + cases := map[string]func(a *AgentProcess) string{ + "too few kicks": func(a *AgentProcess) string { + a.KicksUndeliverable = busyNoActivityRecoveryKicks - 1 + return working + }, + "transcript recently active": func(a *AgentProcess) string { + a.LastTranscriptActivity = now.Add(-45 * time.Minute) + return working + }, + "longer configured threshold": func(a *AgentProcess) string { + a.Config.BusyNoActivityThreshold = time.Hour + return working + }, + "pane no longer working": func(a *AgentProcess) string { + return "❯ " + }, + "bare shell left to crash recovery": func(a *AgentProcess) string { + return "agent@hive:~$ " + }, + "off-prompt pane too few kicks": func(a *AgentProcess) string { + a.KicksUndeliverable = busyNoActivityRecoveryKicks - 1 + return offPrompt + }, + "consent screen on inference backend left to dismissal": func(a *AgentProcess) string { + a.Config.Backend = "vllm" + return consent + }, + "consent screen too few kicks": func(a *AgentProcess) string { + a.KicksUndeliverable = busyNoActivityRecoveryKicks - 1 + return consent + }, + "within restart cooldown": func(a *AgentProcess) string { + a.lastSessionStallRestart = now.Add(-time.Minute) + return working + }, + "no busy episode": func(a *AgentProcess) string { + a.LastTranscriptActivity = time.Time{} + a.BusySince = time.Time{} + return working + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + a := stuck() + pane := mutate(a) + if got := busyNoActivityStall(a, pane, now); got != "" { + t.Fatalf("busyNoActivityStall = %q, want no escalation", got) + } + }) + } +} + +// transcriptRefreshTestManager returns a Manager with one Copilot agent whose +// session-state tree lives under HOME (UID 0 agents run out of the process +// HOME), and that HOME. +func transcriptRefreshTestManager(t *testing.T) (*Manager, string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "copilot"}, + }, slog.New(slog.NewTextHandler(io.Discard, nil)), ProjectContext{}) + return m, home +} + +// AllStatuses is on the hub heartbeat's 10 s collect budget; it must copy the +// cached transcript liveness, never walk the session-state tree itself (a +// cold NFS walk of a 16k-session hive took 8–12 s). The refresher owns the +// walk. +func TestAllStatusesReadsCachedTranscriptActivityWithoutScanning(t *testing.T) { + m, home := transcriptRefreshTestManager(t) + mod := time.Date(2026, 10, 21, 17, 0, 0, 0, time.UTC) + writeEventsFile(t, home, "live", mod) + + if got := m.AllStatuses()["scanner"].LastTranscriptActivity; !got.IsZero() { + t.Fatalf("AllStatuses scanned the session tree itself: LastTranscriptActivity = %v, want zero", got) + } + + now := mod.Add(time.Minute) + caches := map[string]*transcriptSessionCache{} + if n := m.refreshTranscriptActivity(caches, func() time.Time { return now }, nil); n != 1 { + t.Fatalf("refresh scanned %d agents, want 1", n) + } + if got := m.AllStatuses()["scanner"].LastTranscriptActivity; !got.Equal(mod) { + t.Fatalf("LastTranscriptActivity after refresh = %v, want %v", got, mod) + } + // Inside the scan interval the agent is not re-scanned. + if n := m.refreshTranscriptActivity(caches, func() time.Time { return now.Add(time.Second) }, nil); n != 0 { + t.Fatalf("refresh inside the interval scanned %d agents, want 0", n) + } +} + +func TestRunTranscriptActivityRefreshScansOnTickAndStopsOnCancel(t *testing.T) { + m, home := transcriptRefreshTestManager(t) + first := time.Date(2026, 10, 21, 17, 0, 0, 0, time.UTC) + writeEventsFile(t, home, "s1", first) + + // The fake clock is read by the refresher goroutine and advanced by the + // test, so it lives behind an atomic. + var clock atomic.Int64 + clock.Store(first.UnixNano()) + now := func() time.Time { return time.Unix(0, clock.Load()).UTC() } + ticks := make(chan time.Time) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + m.runTranscriptActivityRefresh(ctx, ticks, now) + }() + + // The initial scan runs before the loop waits for its first tick, so a + // tick send is only accepted once it has finished. + later := first.Add(time.Hour) + writeEventsFile(t, home, "s2", later) + tick := first.Add(transcriptLivenessScanInterval) + clock.Store(tick.UnixNano()) + ticks <- tick + // Each further tick send proves the previous pass completed; two more + // guarantee a pass that both started after s2 was written and finished + // before the read below, whatever the initial scan observed. + for range 2 { + tick = tick.Add(transcriptLivenessScanInterval) + clock.Store(tick.UnixNano()) + ticks <- tick + } + if got := m.AllStatuses()["scanner"].LastTranscriptActivity; !got.Equal(later) { + t.Fatalf("LastTranscriptActivity after tick = %v, want %v", got, later) + } + + cancel() + <-done +} + +// Between full rescans only sessions touched within transcriptHotSessionAge +// are stat'd; a cold session's cached mtime is trusted. The full rescan still +// notices a cold session that was resumed. +func TestNewestCopilotTranscriptActivityCachedSkipsColdSessions(t *testing.T) { + home := t.TempDir() + now := time.Date(2026, 10, 21, 17, 0, 0, 0, time.UTC) + hot := now.Add(-time.Minute) + cold := now.Add(-transcriptHotSessionAge - time.Hour) + writeEventsFile(t, home, "hot", hot) + writeEventsFile(t, home, "cold", cold) + if err := os.MkdirAll(filepath.Join(home, ".copilot", "session-state", "no-events"), 0o755); err != nil { + t.Fatal(err) + } + + cache := &transcriptSessionCache{} + got, ok := newestCopilotTranscriptActivityCached(home, cache, now) + if !ok || !got.Equal(hot) { + t.Fatalf("full scan = %v, %v; want %v, true", got, ok, hot) + } + if !cache.lastFull.Equal(now) || len(cache.sessions) != 3 { + t.Fatalf("cache after full scan = %+v, want lastFull=%v and 3 sessions", cache, now) + } + + // The cold session is resumed: on the next (partial) pass it is not + // stat'd, so the cached mtime wins and the newest stays the hot one. + resumed := now.Add(time.Second) + writeEventsFile(t, home, "cold", resumed) + got, ok = newestCopilotTranscriptActivityCached(home, cache, now.Add(transcriptLivenessScanInterval)) + if !ok || !got.Equal(hot) { + t.Fatalf("partial scan = %v, %v; want cached %v, true", got, ok, hot) + } + + // The hot session IS re-stat'd on a partial pass. + hotter := now.Add(2 * time.Second) + writeEventsFile(t, home, "hot", hotter) + got, _ = newestCopilotTranscriptActivityCached(home, cache, now.Add(2*transcriptLivenessScanInterval)) + if !got.Equal(hotter) { + t.Fatalf("partial scan missed the hot session update: %v, want %v", got, hotter) + } + + // A session directory that had no events.jsonl is re-stat'd on a partial + // pass too, so a just-started session is seen as soon as its first event + // lands rather than at the next full rescan. + started := now.Add(3 * time.Second) + writeEventsFile(t, home, "no-events", started) + got, _ = newestCopilotTranscriptActivityCached(home, cache, now.Add(3*transcriptLivenessScanInterval)) + if !got.Equal(started) { + t.Fatalf("partial scan missed the new session's first event: %v, want %v", got, started) + } + + // The full rescan picks the resumed cold session up and drops a removed one. + if err := os.RemoveAll(filepath.Join(home, ".copilot", "session-state", "no-events")); err != nil { + t.Fatal(err) + } + full := now.Add(transcriptFullRescanInterval) + got, _ = newestCopilotTranscriptActivityCached(home, cache, full) + if !got.Equal(hotter) { + t.Fatalf("full rescan newest = %v, want %v", got, hotter) + } + if !cache.sessions["cold"].Equal(resumed) { + t.Fatalf("full rescan did not re-stat the cold session: %v, want %v", cache.sessions["cold"], resumed) + } + if _, still := cache.sessions["no-events"]; still || !cache.lastFull.Equal(full) { + t.Fatalf("cache after full rescan = %+v, want no-events dropped and lastFull=%v", cache, full) + } +} diff --git a/agent/capabilities.go b/agent/capabilities.go new file mode 100644 index 0000000..d2b560b --- /dev/null +++ b/agent/capabilities.go @@ -0,0 +1,102 @@ +package agent + +import ( + "strings" +) + +// AgentCapabilities carries the ORTHOGONAL permissions an agent holds — the +// ones that are not a rung on the AgentMode ladder (#4492). +// +// AgentMode is an ordered trust tier: ADVISORY < ISSUES_ONLY < ISSUES_AND_PRS < +// ISSUES_PRS_MERGE, and everything it gates is a strict superset of the tier +// below. Conversation does not fit that shape. Commenting on an issue is not a +// step between "observe" and "file issues"; it is a different axis. A reviewer +// may reasonably want an advisory agent that talks, or a merge-capable agent +// that stays silent, and neither is expressible by moving along the ladder. +// +// Inserting a fifth ordered mode would also renumber the enum, which is used as +// an array index (modeNames/modeEmojis/modeSuffixes, agentModeCount) and mapped +// to policy-file suffixes, so every agent would need a new policy template for +// a permission that is not a trust tier. +// +// Capabilities are therefore a separate, unordered set. They only ever WIDEN +// what a mode permits — see proxy.AllowedByModeCaps, where a capability is an +// additional grant path checked beside the tier comparison, never a replacement +// for it. A zero AgentCapabilities is exactly today's behaviour. +type AgentCapabilities struct { + // Converse permits conversation-shaped writes — posting a comment on an + // issue or PR, and leaving a PR review — independently of the agent's mode. + // + // It does NOT permit artifact production: creating or editing an issue, + // relabelling, pushing, opening or merging a PR all remain on the mode + // ladder. An ADVISORY agent with Converse can reply on a thread it was + // mentioned in; it still cannot file, edit or relabel anything. + Converse bool +} + +// CanConverse reports whether this agent may post comments and reviews. +func (a AgentCapabilities) CanConverse() bool { return a.Converse } + +// Any reports whether any capability is set. A zero value means "mode alone +// decides", which is how every hive behaved before capabilities existed. +func (a AgentCapabilities) Any() bool { return a.Converse } + +// DefaultCapabilities returns the capabilities an agent holds when its config +// says nothing. +// +// COMPATIBILITY CONTRACT (#4492): this is the zero value at every mode and ACMM +// level, deliberately. `converse` is opt-in everywhere. +// +// The RFC proposed defaulting it on "at exactly the levels where those +// operations are permitted today", but a single flag cannot express that: the +// two operations it governs sit at DIFFERENT tiers today — issue comments at +// ISSUES_ONLY, PR reviews at ISSUES_AND_PRS. Defaulting on at ISSUES_ONLY would +// newly grant PR reviews two tiers early; defaulting on at ISSUES_AND_PRS would +// newly forbid comments an ISSUES_ONLY agent can post now. Either is a +// behaviour change on existing hives. +// +// Keeping the default off, and treating the capability as an ADDITIONAL grant +// beside the unchanged tier floors, gives the compatibility contract the RFC +// actually asked for — every existing hive behaves identically — while still +// making both of the shapes it wanted reachable: +// +// - "advisory agent that talks": mode ADVISORY + converse +// - "comment without issue mutation": mode ADVISORY + converse (an +// ISSUES_ONLY agent keeps issue mutation because that is what its mode +// means, so the narrow grant is expressed by lowering the mode, not by +// subtracting from it) +func DefaultCapabilities(_ AgentMode, _ int) AgentCapabilities { + return AgentCapabilities{} +} + +// capabilityConverse is the token used in the on-disk capability file and in +// the `converse` agent config field. +const capabilityConverse = "converse" + +// String renders the capability set as the comma-separated token list written +// to /tmp/.hive-caps- and read back by the proxy. An empty set renders +// as the empty string, so the file for a default agent is empty rather than +// absent-with-meaning. +func (a AgentCapabilities) String() string { + var caps []string + if a.Converse { + caps = append(caps, capabilityConverse) + } + return strings.Join(caps, ",") +} + +// ParseCapabilities reads the token list String() writes. Unknown tokens are +// ignored rather than rejected: this file is read by the proxy on the request +// path, and a token from a newer hive version must degrade to "capability not +// held" — the deny-by-default direction — not to a parse error that would be +// indistinguishable from a missing file. +func ParseCapabilities(s string) AgentCapabilities { + var a AgentCapabilities + for _, tok := range strings.Split(s, ",") { + switch strings.ToLower(strings.TrimSpace(tok)) { + case capabilityConverse: + a.Converse = true + } + } + return a +} diff --git a/agent/caveman_install_test.go b/agent/caveman_install_test.go new file mode 100644 index 0000000..ba52d79 --- /dev/null +++ b/agent/caveman_install_test.go @@ -0,0 +1,117 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// TestCavemanInstallArgvRunsAsAgentUser pins the fix for the caveman clobber: +// the installer spawns the backend's own CLI (`claude plugin list` / `plugin +// install`), and Claude Code rewrites $HOME/.claude.json wholesale on every +// invocation. Run as the hive user with the agent's HOME, that CLI cannot +// read the agent-owned 0600 session file, treats the home as a fresh +// install, and replaces the signed-in session with a blank skeleton — the +// agent drops to the login menu on its next start (#4596 reintroduced from +// inside the manager, through the per-agent layout #4619 built to end it). +// Every per-UID install must therefore be wrapped in su-exec, exactly like +// tmuxCmd and setupCodexHome. +func TestCavemanInstallArgvRunsAsAgentUser(t *testing.T) { + backends := []string{"claude", "copilot", "gemini", "goose", "codex", "aider"} + for _, backend := range backends { + argv := cavemanInstallArgv(backend, "full", "hive-scanner") + if len(argv) < 3 || argv[0] != "su-exec" || argv[1] != "hive-scanner" { + t.Errorf("backend %s: per-UID install must run via su-exec as the agent user, got %v", backend, argv) + continue + } + if argv[2] != "npx" { + t.Errorf("backend %s: expected npx after the su-exec prefix, got %v", backend, argv) + } + } +} + +// TestCavemanInstallArgvSharedUIDStaysDirect pins the UID==0 path: agents +// without an allocated UID run as the hive user, so there is no user to +// switch to and no su-exec prefix (su-exec may not even exist on such +// deployments). +func TestCavemanInstallArgvSharedUIDStaysDirect(t *testing.T) { + argv := cavemanInstallArgv("claude", "minimal", "") + if len(argv) == 0 || argv[0] != "npx" { + t.Errorf("shared-UID install must invoke npx directly, got %v", argv) + } + for _, a := range argv { + if a == "su-exec" { + t.Errorf("shared-UID install must not use su-exec: %v", argv) + } + } +} + +func TestCavemanInstallArgvUnsupportedBackend(t *testing.T) { + if argv := cavemanInstallArgv("mystery", "full", "hive-x"); argv != nil { + t.Errorf("unsupported backend must return nil, got %v", argv) + } +} + +// TestCavemanNpmCachePathReplacesForeignOwnedCache covers the migration +// burn: caches written back when the install ran as the hive user are owned +// by the hive UID, and npm run as the agent UID EACCESes on those shards +// (the same failure class as #2284). A cache owned by another UID must be +// removed so the agent-owned install starts clean. +func TestCavemanNpmCachePathReplacesForeignOwnedCache(t *testing.T) { + agentDir := t.TempDir() + cache := filepath.Join(agentDir, ".npm-caveman-cache") + if err := os.MkdirAll(filepath.Join(cache, "_cacache"), 0o755); err != nil { + t.Fatalf("mkdir cache: %v", err) + } + + // Any UID other than the one that owns the dir we just made. + foreignUID := os.Getuid() + 1 + got := cavemanNpmCachePath(agentDir, foreignUID) + if got != cache { + t.Errorf("removable foreign cache should be replaced in place, got %q", got) + } + if _, err := os.Stat(cache); !os.IsNotExist(err) { + t.Errorf("foreign-owned cache should have been removed, stat err = %v", err) + } +} + +func TestCavemanNpmCachePathKeepsOwnCache(t *testing.T) { + agentDir := t.TempDir() + cache := filepath.Join(agentDir, ".npm-caveman-cache") + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatalf("mkdir cache: %v", err) + } + if got := cavemanNpmCachePath(agentDir, os.Getuid()); got != cache { + t.Errorf("cache already owned by the agent must be kept, got %q", got) + } + if _, err := os.Stat(cache); err != nil { + t.Errorf("agent-owned cache must not be removed: %v", err) + } +} + +// TestCavemanNpmCachePathFallsBackWhenRemovalFails pins the last-resort +// path: a foreign-owned cache that cannot be removed is sidestepped with a +// per-UID dir rather than allowed to fail the install with EACCES. +func TestCavemanNpmCachePathFallsBackWhenRemovalFails(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root — removal cannot be made to fail with permissions") + } + agentDir := t.TempDir() + cache := filepath.Join(agentDir, ".npm-caveman-cache") + if err := os.MkdirAll(filepath.Join(cache, "_cacache"), 0o755); err != nil { + t.Fatalf("mkdir cache: %v", err) + } + // Read-only parent makes the unlink inside RemoveAll fail. + if err := os.Chmod(agentDir, 0o500); err != nil { + t.Fatalf("chmod agent dir: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(agentDir, 0o755) }) + + foreignUID := os.Getuid() + 1 + got := cavemanNpmCachePath(agentDir, foreignUID) + want := fmt.Sprintf("%s-u%d", cache, foreignUID) + if got != want { + t.Errorf("unremovable foreign cache must fall back to a per-UID path: got %q want %q", got, want) + } +} diff --git a/agent/caveman_pin_test.go b/agent/caveman_pin_test.go new file mode 100644 index 0000000..b32ae46 --- /dev/null +++ b/agent/caveman_pin_test.go @@ -0,0 +1,76 @@ +package agent + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// TestCavemanPinIsNotBareSHA guards the caveman skill pin format in +// installCavemanForAgent (manager.go). +// +// The `skills` CLI (and npx's git resolution for `skills add`) clones with +// `git clone --depth 1 --branch `, and git cannot clone a bare commit +// SHA as a branch ("Could not find remote branch "). A raw-SHA pin +// therefore silently breaks every caveman install on a live hive — this +// happened with the 0d95a81d35a9 pin (fixed by re-pinning to the equivalent +// tag v1.9.1 in PR #4518). Pins must be branch or tag names. +// +// This test scans manager.go for caveman refs and fails if any ref after '#' +// is a bare hex SHA (7-40 hex chars), so the regression cannot be +// reintroduced. +func TestCavemanPinIsNotBareSHA(t *testing.T) { + src, err := os.ReadFile("manager_launch.go") + if err != nil { + t.Fatalf("reading manager.go: %v", err) + } + + // Match both pin forms: "github:JuliusBrussee/caveman#" (npx) and + // "JuliusBrussee/caveman#" (skills add). + refRe := regexp.MustCompile(`(?:github:)?JuliusBrussee/caveman#([^"\s]+)`) + bareSHARe := regexp.MustCompile(`^[0-9a-f]{7,40}$`) + + matches := refRe.FindAllStringSubmatch(string(src), -1) + if len(matches) == 0 { + t.Fatal("no caveman refs found in manager.go; if the pin moved, update this test to scan the new location") + } + + for _, m := range matches { + ref := m[1] + if bareSHARe.MatchString(ref) { + t.Errorf("caveman ref %q is pinned to a bare commit SHA; `git clone --branch ` cannot clone it, so every install fails. Pin a tag (or branch) that resolves to the wanted commit instead — see PR #4518", m[0]) + } + } +} + +// TestCavemanSkillsAddIsHeadless guards the other silent-failure mode of the +// same install path: without the skills CLI's own `-y`, `skills add -a +// ` renders an interactive confirmation that auto-cancels in the +// hive's non-TTY captured-output context — the command exits 0 but installs +// NOTHING (npx's `-y` only confirms the npx package fetch, not the skills +// prompts). See PR #4548. Every `skills add` invocation in manager.go must +// therefore carry a `-y` argument. +func TestCavemanSkillsAddIsHeadless(t *testing.T) { + src, err := os.ReadFile("manager_launch.go") + if err != nil { + t.Fatalf("reading manager.go: %v", err) + } + + addLineRe := regexp.MustCompile(`(?m)^.*"skills",\s*"add".*$`) + // Only args AFTER "add" count: npx's own leading `-y` confirms the npx + // package fetch, not the skills CLI's prompts. + yesRe := regexp.MustCompile(`"(-y|--yes)"`) + + lines := addLineRe.FindAllString(string(src), -1) + if len(lines) == 0 { + t.Fatal("no `skills add` invocations found in manager.go; if the install moved, update this test to scan the new location") + } + + for _, line := range lines { + _, afterAdd, _ := strings.Cut(line, `"add"`) + if !yesRe.MatchString(afterAdd) { + t.Errorf("`skills add` invocation lacks the skills CLI's own -y/--yes flag after `add` (npx's leading -y does not count), so it auto-cancels headlessly and installs nothing (exit 0): %s — see PR #4548", strings.TrimSpace(line)) + } + } +} diff --git a/agent/ci_poll_pr_view_test.go b/agent/ci_poll_pr_view_test.go new file mode 100644 index 0000000..a338746 --- /dev/null +++ b/agent/ci_poll_pr_view_test.go @@ -0,0 +1,47 @@ +package agent + +import "testing" + +// TestCountCIPollCommandsDetectsPRViewStatusCheck covers a gap in the #9673 +// harness guard: agents often check their own PR's CI status with +// `gh pr view --json statusCheckRollup` rather than `gh run watch/view` or +// `gh pr checks`, and that command bypassed countCIPollCommands entirely +// before ciPollCommandRe learned to recognize it. A plain `gh pr view` used +// to read PR metadata (title, author, files, ...) must still be ignored so +// the guard does not nudge agents for ordinary, non-CI PR lookups. +func TestCountCIPollCommandsDetectsPRViewStatusCheck(t *testing.T) { + tests := []struct { + name string + pane string + want int + }{ + { + name: "pr view polling CI status is counted", + pane: "gh pr view 123 --json statusCheckRollup", + want: 1, + }, + { + name: "pr view for ordinary metadata is not counted", + pane: "gh pr view 123 --json title,author,files", + want: 0, + }, + { + name: "pr status is counted", + pane: "gh pr status", + want: 1, + }, + { + name: "mixed pane counts each poll command once", + pane: "gh pr view 123 --json title\ngh run watch 6\ngh pr view 123 --json statusCheckRollup", + want: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := countCIPollCommands(tt.pane); got != tt.want { + t.Errorf("countCIPollCommands(%q) = %d, want %d", tt.pane, got, tt.want) + } + }) + } +} diff --git a/agent/ci_poll_prohibition_test.go b/agent/ci_poll_prohibition_test.go new file mode 100644 index 0000000..9de3bd9 --- /dev/null +++ b/agent/ci_poll_prohibition_test.go @@ -0,0 +1,36 @@ +package agent + +import "testing" + +// The kick prompt quotes the polling commands as prohibitions; pasting it +// into the pane must not count as the agent polling CI (#13648). +func TestCountCIPollCommandsIgnoresProhibitions(t *testing.T) { + tests := []struct { + name string + pane string + want int + }{ + { + name: "kick prompt prohibition", + pane: "After the push, STOP: do not watch, poll, or sleep on CI (no gh run watch/view loops).", + want: 0, + }, + { + name: "policy text with backticks", + pane: "sleep on CI** — no `gh run watch`, no `gh run view` loops, no `gh pr checks` loops", + want: 0, + }, + { + name: "real command after prohibition text on another line", + pane: "no `gh run watch` loops\n$ gh run watch 42", + want: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := countCIPollCommands(tt.pane); got != tt.want { + t.Errorf("countCIPollCommands(%q) = %d, want %d", tt.pane, got, tt.want) + } + }) + } +} diff --git a/agent/ci_poll_sweep_test.go b/agent/ci_poll_sweep_test.go new file mode 100644 index 0000000..a50a5d1 --- /dev/null +++ b/agent/ci_poll_sweep_test.go @@ -0,0 +1,189 @@ +package agent + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// ci_poll_sweep_test.go pins #14016: the scanner's merge sweep and CI-repair +// pass read `gh pr checks` once for each of many PRs. That is a sweep, not a +// wait on one PR, so only command lines run more than once count toward the +// nudge threshold and the fleet self-report, and the wall-clock trigger +// leaves a sweep alone (#14066). + +func TestCountRepeatedCIPollCommands(t *testing.T) { + tests := []struct { + name string + pane string + want int + }{ + { + name: "distinct PRs read once each", + pane: "gh pr checks 1\ngh pr checks 2\ngh pr checks 3\ngh pr checks 4", + want: 0, + }, + { + name: "same PR polled counts every read", + pane: "gh pr checks 7\ngh pr checks 8\ngh pr checks 7\ngh pr checks 7", + want: 3, + }, + { + name: "pane padding and box rule do not split one target", + pane: "● gh run watch 42 ┃\n gh run watch 42\n", + want: 2, + }, + { + name: "quoted prohibition is not a read", + pane: "no `gh pr checks` loops\ngh pr checks 5", + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := countRepeatedCIPollCommands(tt.pane, nil); got != tt.want { + t.Errorf("countRepeatedCIPollCommands(%q) = %d, want %d", tt.pane, got, tt.want) + } + }) + } +} + +func TestCountRepeatedCIPollCommandsSinceBaseline(t *testing.T) { + const poll = "gh pr checks 7\n" + for _, tt := range []struct { + name string + baseline string + pane string + want int + }{ + {name: "one old and one new read", baseline: poll, pane: poll + poll, want: 0}, + {name: "old repeated reads and one new read", baseline: poll + poll, pane: poll + poll + poll, want: 0}, + {name: "two new reads after old singleton", baseline: poll, pane: strings.Repeat(poll, 3), want: 2}, + {name: "two new reads after old repeats", baseline: poll + poll, pane: strings.Repeat(poll, 4), want: 2}, + {name: "unchanged scrollback", baseline: poll + poll, pane: poll + poll, want: 0}, + {name: "old target scrolled out, new target repeats", baseline: poll + poll, pane: "gh pr checks 8\ngh pr checks 8\n", want: 2}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := countRepeatedCIPollCommands(tt.pane, ciPollCommandCounts(tt.baseline)); got != tt.want { + t.Errorf("repeated reads since baseline = %d, want %d", got, tt.want) + } + }) + } +} + +// A scanner revisits the same PRs on successive kicks. Each sweep reads each +// PR only once; historical reads must not cause a count nudge or fleet report. +func TestNudgeIfPollingCISuccessiveSweeps(t *testing.T) { + var sweep string + for pr := range ciPollNudgeThreshold + 3 { + sweep += fmt.Sprintf("gh pr checks %d --repo acme/widgets\n", 100+pr) + } + for _, tt := range []struct { + name string + kickAge time.Duration + wantNudged bool + }{ + {name: "young kick", kickAge: 2 * time.Minute}, + {name: "past wall clock", kickAge: 11 * time.Minute}, + } { + t.Run(tt.name, func(t *testing.T) { + m, a := ciPollManager(t, ciPollIdlePane, tt.kickAge) + tr := recordConditions(t, m) + scrollback := sweep + for kick := range 3 { + m.mu.Lock() + m.recordDeliveredKickLocked(a, "inspect PRs", "test") + *a.LastKick = time.Now().Add(-tt.kickAge) + m.mu.Unlock() + + m.nudgeIfPollingCI(a, scrollback, ciPollIdlePane) + scrollback += sweep + m.nudgeIfPollingCI(a, scrollback, ciPollIdlePane) + + if a.ciPollNudgeSent != tt.wantNudged || a.AwaitingCI != tt.wantNudged { + t.Fatalf("kick %d: nudged=%v awaiting=%v, want %v", kick, a.ciPollNudgeSent, a.AwaitingCI, tt.wantNudged) + } + if ev := ciPollEvidence(tr); len(ev) != 0 { + t.Fatalf("kick %d: sweep produced CI-poll evidence: %#v", kick, ev) + } + } + }) + } +} + +func TestNudgeIfPollingCISweepIsNotPolling(t *testing.T) { + sweep := "" + for pr := range ciPollNudgeThreshold + 3 { + sweep += fmt.Sprintf("gh pr checks %d --repo acme/widgets\n", 100+pr) + } + for _, tt := range []struct { + name string + kickAge time.Duration + wantNudged bool + }{ + {name: "sweep, kick young: no nudge", kickAge: 2 * time.Minute, wantNudged: false}, + {name: "sweep past wall clock: no nudge (#14066)", kickAge: 11 * time.Minute, wantNudged: false}, + } { + t.Run(tt.name, func(t *testing.T) { + m, a := ciPollManager(t, ciPollIdlePane, tt.kickAge) + tr := recordConditions(t, m) + + m.nudgeIfPollingCI(a, "", ciPollIdlePane) + m.nudgeIfPollingCI(a, sweep, ciPollIdlePane) + + if a.AwaitingCI != tt.wantNudged { + t.Errorf("AwaitingCI = %v, want %v", a.AwaitingCI, tt.wantNudged) + } + + if a.ciPollNudgeSent != tt.wantNudged { + t.Errorf("ciPollNudgeSent = %v, want %v", a.ciPollNudgeSent, tt.wantNudged) + } + if ev := ciPollEvidence(tr); len(ev) != 0 { + t.Errorf("ci_poll_nudge evidence = %d for a sweep, want 0: %#v", len(ev), ev) + } + }) + } +} + +// TestNudgeIfPollingCIWallClockSweepWithRepeat pins #14066's boundary: past +// the wall clock a sweep is left alone, but one PR read twice inside it is a +// wait on that PR, so the wall-clock nudge still fires. +func TestNudgeIfPollingCIWallClockSweepWithRepeat(t *testing.T) { + sweep := "gh pr checks 100\ngh pr checks 101\ngh pr checks 102\n" + for _, tt := range []struct { + name string + pane string + wantNudged bool + }{ + {name: "distinct reads", pane: sweep, wantNudged: false}, + {name: "one target re-read", pane: sweep + "gh pr checks 101\n", wantNudged: true}, + } { + t.Run(tt.name, func(t *testing.T) { + m, a := ciPollManager(t, ciPollIdlePane, 11*time.Minute) + + m.nudgeIfPollingCI(a, "", ciPollIdlePane) + m.nudgeIfPollingCI(a, tt.pane, ciPollIdlePane) + + if a.ciPollNudgeSent != tt.wantNudged || a.AwaitingCI != tt.wantNudged { + t.Errorf("nudged=%v awaiting=%v, want %v", a.ciPollNudgeSent, a.AwaitingCI, tt.wantNudged) + } + }) + } +} + +func TestCIPollSweep(t *testing.T) { + for _, tt := range []struct { + polls, repeats int + want bool + }{ + {polls: 0, repeats: 0, want: false}, + {polls: 1, repeats: 0, want: false}, + {polls: 2, repeats: 0, want: true}, + {polls: 3, repeats: 2, want: false}, + } { + if got := ciPollSweep(tt.polls, tt.repeats); got != tt.want { + t.Errorf("ciPollSweep(%d, %d) = %v, want %v", tt.polls, tt.repeats, got, tt.want) + } + } +} diff --git a/agent/ci_poll_wall_clock_test.go b/agent/ci_poll_wall_clock_test.go new file mode 100644 index 0000000..577b51e --- /dev/null +++ b/agent/ci_poll_wall_clock_test.go @@ -0,0 +1,221 @@ +package agent + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/hivecommons/crux" + "github.com/hivecommons/crux/config" +) + +// ci_poll_wall_clock_test.go covers the second half of #9673's harness-guard +// ask: a single blocking `gh run watch` sits under ciPollNudgeThreshold (it +// is one command) but can still burn the whole kick, so nudgeIfPollingCI must +// also trigger once the kick has run past ciPollNudgeWallClock with at least +// one poll command seen. The count-threshold path (#9702) already had this +// coverage indirectly; these tests pin the new OR branch explicitly. + +// ciPollManager builds a manager holding one CLI-backend agent whose pane and +// tmux calls are served from seams, mirroring nudgeManager in +// transient_api_error_nudge_test.go so no tmux server is involved. +func ciPollManager(t *testing.T, pane string, kickAge time.Duration) (*Manager, *AgentProcess) { + t.Helper() + m := testManager(4) + kickedAt := time.Now().Add(-kickAge) + a := &AgentProcess{ + Name: "scanner", + Config: config.AgentConfig{Backend: "copilot"}, + tmuxSession: "hive-scanner-test-nonexistent", + LastKick: &kickedAt, + ciPollBaseline: -1, + } + m.agents[a.Name] = a + termSeams(m).captureVisiblePane = func(*AgentProcess) string { return pane } + termSeams(m).sessionAttached = func(*AgentProcess) bool { return false } + termSeams(m).sendLiteral = func(*AgentProcess, string) {} + return m, a +} + +const ciPollIdlePane = "● Running `gh run watch 123`\n\n" + cliInputPromptMarker + " " + +func TestNudgeIfPollingCIWallClock(t *testing.T) { + tests := []struct { + name string + kickAge time.Duration + polls int // number of scrollback captures showing one new poll command each + wantNudged bool + wantAwaited bool + }{ + { + name: "one poll, kick young: neither trigger fires", + kickAge: 2 * time.Minute, + polls: 1, + wantNudged: false, + wantAwaited: false, + }, + { + name: "one poll, kick past wall clock: wall-clock trigger fires", + kickAge: 11 * time.Minute, + polls: 1, + wantNudged: true, + wantAwaited: true, + }, + { + name: "many polls, kick young: count trigger still fires", + kickAge: 2 * time.Minute, + polls: ciPollNudgeThreshold + 1, + wantNudged: true, + wantAwaited: true, + }, + { + name: "zero new polls, kick past wall clock: no trigger", + kickAge: 11 * time.Minute, + polls: 0, + wantNudged: false, + wantAwaited: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, a := ciPollManager(t, ciPollIdlePane, tt.kickAge) + + // Baseline capture establishes ciPollBaseline from an empty pane + // so the poll commands below all read as "new this kick". + m.nudgeIfPollingCI(a, "", ciPollIdlePane) + + scrollback := "" + for i := 0; i < tt.polls; i++ { + scrollback += "gh run watch 123\n" + } + m.nudgeIfPollingCI(a, scrollback, ciPollIdlePane) + + if a.ciPollNudgeSent != tt.wantNudged { + t.Errorf("ciPollNudgeSent = %v, want %v", a.ciPollNudgeSent, tt.wantNudged) + } + if a.AwaitingCI != tt.wantAwaited { + t.Errorf("AwaitingCI = %v, want %v", a.AwaitingCI, tt.wantAwaited) + } + }) + } +} + +// TestNudgeIfPollingCIWallClockSecondNudgeSuppressed confirms the wall-clock +// path still honors the existing at-most-one-nudge-per-kick cap. +func TestNudgeIfPollingCIWallClockSecondNudgeSuppressed(t *testing.T) { + m, a := ciPollManager(t, ciPollIdlePane, 11*time.Minute) + m.nudgeIfPollingCI(a, "", ciPollIdlePane) + m.nudgeIfPollingCI(a, "gh run watch 123\n", ciPollIdlePane) + if !a.ciPollNudgeSent { + t.Fatal("expected first wall-clock nudge to fire") + } + nudgesAfterFirst := a.CIPollNudges + + m.nudgeIfPollingCI(a, "gh run watch 123\ngh run watch 123\n", ciPollIdlePane) + if a.CIPollNudges != nudgesAfterFirst { + t.Errorf("CIPollNudges = %d, want unchanged at %d (already nudged this kick)", a.CIPollNudges, nudgesAfterFirst) + } +} + +// TestNudgeIfPollingCISkipsMonitoringLanes pins #13860 and #13898: reviewers +// and CI maintainers read check status as evidence for the PRs and repositories +// they monitor, so that work must not be mistaken for polling their own PR. +func TestNudgeIfPollingCISkipsMonitoringLanes(t *testing.T) { + for _, tt := range []struct { + name string + agentName string + role string + wantNudged bool + }{ + {name: "reviewer role", agentName: "scanner", role: ReviewerRole, wantNudged: false}, + {name: "reviewer name only", agentName: "code-reviewer-sonnet-1", role: "", wantNudged: false}, + {name: "CI maintainer role", agentName: "worker", role: "ci-maintainer", wantNudged: false}, + {name: "CI maintainer name only", agentName: "ci-maintainer", role: "", wantNudged: false}, + {name: "developer", agentName: "scanner", role: "developer", wantNudged: true}, + } { + t.Run(tt.name, func(t *testing.T) { + m, a := ciPollManager(t, ciPollIdlePane, 2*time.Minute) + tr := recordConditions(t, m) + a.Name = tt.agentName + a.Config.Role = tt.role + sent := 0 + termSeams(m).sendLiteral = func(*AgentProcess, string) { sent++ } + + m.nudgeIfPollingCI(a, "", ciPollIdlePane) + scrollback := "" + for range ciPollNudgeThreshold + 2 { + scrollback += "gh run list --repo acme/widgets\n" + } + m.nudgeIfPollingCI(a, scrollback, ciPollIdlePane) + + if a.ciPollNudgeSent != tt.wantNudged { + t.Errorf("ciPollNudgeSent = %v, want %v", a.ciPollNudgeSent, tt.wantNudged) + } + if a.AwaitingCI != tt.wantNudged { + t.Errorf("AwaitingCI = %v, want %v", a.AwaitingCI, tt.wantNudged) + } + if (a.CIPollNudges > 0) != tt.wantNudged { + t.Errorf("CIPollNudges = %d, wantNudged %v", a.CIPollNudges, tt.wantNudged) + } + if !tt.wantNudged { + if sent != 0 { + t.Errorf("sent %d literals to monitoring lane, want 0", sent) + } + if ev := ciPollEvidence(tr); len(ev) != 0 { + t.Errorf("ci_poll_nudge evidence = %d for monitoring lane, want 0: %#v", len(ev), ev) + } + } + }) + } +} + +// TestNudgeIfPollingCISelfReportGate pins #13863: a single poll past the +// wall clock still gets the in-session nudge but produces no ci_poll_nudge +// fleet evidence; repeated polling produces exactly one. +func TestNudgeIfPollingCISelfReportGate(t *testing.T) { + for _, tt := range []struct { + name string + kickAge time.Duration + polls int + wantEvidence int + }{ + {name: "one poll past wall clock: nudged, not reported", kickAge: 11 * time.Minute, polls: 1, wantEvidence: 0}, + {name: "repeated polls past wall clock: nudged and reported", kickAge: 11 * time.Minute, polls: ciPollReportMinPolls, wantEvidence: 1}, + {name: "count threshold crossed: nudged and reported", kickAge: 2 * time.Minute, polls: ciPollNudgeThreshold + 1, wantEvidence: 1}, + } { + t.Run(tt.name, func(t *testing.T) { + m, a := ciPollManager(t, ciPollIdlePane, tt.kickAge) + tr := recordConditions(t, m) + sent := 0 + termSeams(m).sendLiteral = func(*AgentProcess, string) { sent++ } + + m.nudgeIfPollingCI(a, "", ciPollIdlePane) + scrollback := "" + for i := 0; i < tt.polls; i++ { + scrollback += "gh pr checks 123\n" + } + m.nudgeIfPollingCI(a, scrollback, ciPollIdlePane) + + if !a.ciPollNudgeSent || sent != 1 { + t.Fatalf("ciPollNudgeSent = %v, literals sent = %d, want nudge sent once", a.ciPollNudgeSent, sent) + } + ev := ciPollEvidence(tr) + if len(ev) != tt.wantEvidence { + t.Fatalf("ci_poll_nudge evidence = %d, want %d: %#v", len(ev), tt.wantEvidence, ev) + } + if tt.wantEvidence == 1 { + for _, want := range []string{"agent=scanner", fmt.Sprintf("polls_since_kick=%d", tt.polls), fmt.Sprintf("report_min_polls=%d", ciPollReportMinPolls)} { + if !strings.Contains(ev[0].Diagnostics, want) { + t.Errorf("diagnostics missing %q:\n%s", want, ev[0].Diagnostics) + } + } + } + }) + } +} + +func ciPollEvidence(rec *conditionRecorder) []conditionObservation { + return rec.observations(crux.ClassCIPollNudge) +} diff --git a/agent/claude_credential_paths_test.go b/agent/claude_credential_paths_test.go new file mode 100644 index 0000000..64b3770 --- /dev/null +++ b/agent/claude_credential_paths_test.go @@ -0,0 +1,180 @@ +package agent + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/hivecommons/crux/config" +) + +// claude_credential_paths_test.go covers ClaudeCredentialCandidatePaths, the +// single answer to "where do this hive's claude credentials live?" (#4699). +// +// It exists because that question previously had TWO answers. This package's +// auth probe learned about per-UID agent homes; the dashboard's model-discovery +// probe did not, and kept stat'ing only the shared path. On a per-UID fleet the +// two disagreed and the operator got a self-contradicting UI — "✓ logged in" +// beside a model list where every entry read "(common alias, unverified)". + +// withTestAgentHomes redirects the per-agent home root at a temp dir so these +// tests never depend on a real /data volume. +func withTestAgentHomes(t *testing.T) string { + t.Helper() + root := t.TempDir() + orig := sharedAgentHome + sharedAgentHome = root + t.Cleanup(func() { sharedAgentHome = orig }) + return root +} + +func claudeAgent(name string, uid int) *AgentProcess { + return &AgentProcess{Name: name, UID: uid, Config: config.AgentConfig{Backend: "claude"}} +} + +func indexOfPath(paths []string, want string) int { + for i, p := range paths { + if p == want { + return i + } + } + return -1 +} + +// TestClaudeCredentialCandidatePathsPerUIDFirst is the fix: the per-UID home an +// agent's own CLI writes to must be tried BEFORE the shared legacy path. On a +// per-UID spoke the shared path is empty while the agent is authenticated, so +// ordering is what separates "found the token" from the all-unverified list. +func TestClaudeCredentialCandidatePathsPerUIDFirst(t *testing.T) { + withTestAgentHomes(t) + m := testManager(4) + m.agents["scanner"] = claudeAgent("scanner", 2007) + + paths := m.ClaudeCredentialCandidatePaths() + + perUID := filepath.Join(AgentHome("scanner", 2007, "claude"), ".claude", ".credentials.json") + pi, si := indexOfPath(paths, perUID), indexOfPath(paths, sharedClaudeCredentialPath) + if pi < 0 { + t.Fatalf("per-UID path %q absent from %v — this is the #4699 bug", perUID, paths) + } + if si < 0 { + t.Fatalf("shared path absent from %v", paths) + } + if pi > si { + t.Errorf("per-UID path at %d comes AFTER the shared path at %d — the agent's own credential must win", pi, si) + } +} + +// TestClaudeCredentialCandidatePathsMatchesAuthProbe pins the anti-drift +// property that is the whole reason this helper exists: whatever +// agentClaudeCredentialPaths would try for an agent, the backend-level list +// must also try. If these two ever diverge again, the dashboard and the agent +// card go back to disagreeing. +func TestClaudeCredentialCandidatePathsMatchesAuthProbe(t *testing.T) { + withTestAgentHomes(t) + m := testManager(4) + m.agents["scanner"] = claudeAgent("scanner", 2007) + + got := m.ClaudeCredentialCandidatePaths() + for _, want := range agentClaudeCredentialPaths("scanner", 2007, "claude") { + if indexOfPath(got, want) < 0 { + t.Errorf("auth probe would try %q but model discovery would not — the two probes have drifted (%v)", want, got) + } + } +} + +// TestClaudeCredentialCandidatePathsNoAgents: a hive whose agents have not +// started yet still has a shared login worth checking. +func TestClaudeCredentialCandidatePathsNoAgents(t *testing.T) { + withTestAgentHomes(t) + m := testManager(4) + + got := m.ClaudeCredentialCandidatePaths() + if len(got) != 1 || got[0] != sharedClaudeCredentialPath { + t.Errorf("got %v, want just the shared path — an agentless hive must still probe it", got) + } +} + +// TestClaudeCredentialCandidatePathsNilManager: callers without an agent +// manager must be no worse off than before the change. +func TestClaudeCredentialCandidatePathsNilManager(t *testing.T) { + var m *Manager + got := m.ClaudeCredentialCandidatePaths() + if len(got) != 1 || got[0] != sharedClaudeCredentialPath { + t.Errorf("got %v, want the shared path alone (pre-per-UID behavior)", got) + } +} + +// TestClaudeCredentialCandidatePathsSkipsOtherBackends: a copilot agent's home +// holds no claude credential, so probing it is a wasted stat that would also +// make the list grow with every unrelated agent. +func TestClaudeCredentialCandidatePathsSkipsOtherBackends(t *testing.T) { + withTestAgentHomes(t) + m := testManager(4) + m.agents["ci"] = &AgentProcess{Name: "ci", UID: 2010, Config: config.AgentConfig{Backend: "copilot"}} + + got := m.ClaudeCredentialCandidatePaths() + for _, p := range got { + if strings.Contains(p, "/ci/") { + t.Errorf("copilot agent's home %q probed for a claude credential (%v)", p, got) + } + } +} + +// TestClaudeCredentialCandidatePathsHonoursBackendOverride: an agent overridden +// to claude at runtime is a claude agent for this purpose — the same +// effective-backend rule the auth probe applies. +func TestClaudeCredentialCandidatePathsHonoursBackendOverride(t *testing.T) { + withTestAgentHomes(t) + m := testManager(4) + m.agents["quality"] = &AgentProcess{ + Name: "quality", UID: 2011, + Config: config.AgentConfig{Backend: "copilot"}, + BackendOverride: "claude", + } + + got := m.ClaudeCredentialCandidatePaths() + want := filepath.Join(AgentHome("quality", 2011, "claude"), ".claude", ".credentials.json") + if indexOfPath(got, want) < 0 { + t.Errorf("override to claude not honoured: %q absent from %v", want, got) + } +} + +// TestClaudeCredentialCandidatePathsDeterministic: m.agents iterates randomly, +// so without sorting the probe would consult a different credential per call — +// making an expired token an intermittent failure and this suite flaky. +func TestClaudeCredentialCandidatePathsDeterministic(t *testing.T) { + withTestAgentHomes(t) + m := testManager(4) + for _, n := range []string{"scanner", "quality", "guide", "ci-maintainer"} { + m.agents[n] = claudeAgent(n, 2000+len(n)) + } + + first := strings.Join(m.ClaudeCredentialCandidatePaths(), "\n") + for i := 0; i < 20; i++ { + if got := strings.Join(m.ClaudeCredentialCandidatePaths(), "\n"); got != first { + t.Fatalf("order changed between calls:\n%s\nvs\n%s", first, got) + } + } +} + +// TestClaudeCredentialCandidatePathsDedupes: under HIVE_SHARED_AGENT_HOME every +// agent resolves to the same home, so without dedup the list would repeat one +// path per agent and the probe would stat it N times. +func TestClaudeCredentialCandidatePathsDedupes(t *testing.T) { + withTestAgentHomes(t) + t.Setenv("HIVE_SHARED_AGENT_HOME", "1") + m := testManager(4) + for _, n := range []string{"scanner", "quality", "guide"} { + m.agents[n] = claudeAgent(n, 2000+len(n)) + } + + got := m.ClaudeCredentialCandidatePaths() + seen := map[string]bool{} + for _, p := range got { + if seen[p] { + t.Errorf("duplicate path %q in %v", p, got) + } + seen[p] = true + } +} diff --git a/agent/claude_launch_test.go b/agent/claude_launch_test.go new file mode 100644 index 0000000..b33a922 --- /dev/null +++ b/agent/claude_launch_test.go @@ -0,0 +1,137 @@ +package agent + +import ( + "strings" + "testing" + + "github.com/hivecommons/crux/config" +) + +// Launch-command tests for the per-agent reasoning effort on the claude +// backend (hivecommons/hive#8377): `--effort ` rides on the claude command +// line only when an effort is configured, only for a value Claude Code +// accepts, and only for claude — every other backend without an effort +// control ignores the field rather than growing a flag it cannot parse. + +// claudeLaunchCmd builds the claude launch command the way Start does for an +// agent with no ToolsConfig, mirroring agyLaunchCmd / codexLaunchCmd. +func claudeLaunchCmd(t *testing.T, model, effort string, isInference bool) string { + t.Helper() + return backendLaunchCmd("claude", normalizeModelNameForBackend(model, config.ClaudeBackend, isInference), config.ClaudeBackend, isInference, effort) +} + +func TestClaudeEffortFlag(t *testing.T) { + cases := []struct { + effort, want string + }{ + {"", ""}, + {config.ClaudeEffortLow, " --effort low"}, + {config.ClaudeEffortMedium, " --effort medium"}, + {config.ClaudeEffortHigh, " --effort high"}, + {config.ClaudeEffortXHigh, " --effort xhigh"}, + {config.ClaudeEffortMax, " --effort max"}, + {"minimal", ""}, // codex-only value: dropped, never passed + {"ultra", ""}, // muse-only value: dropped + {"HIGH", ""}, // no case folding; the CLI's set is lowercase + } + for _, c := range cases { + if got := claudeEffortFlag(c.effort); got != c.want { + t.Errorf("claudeEffortFlag(%q) = %q, want %q", c.effort, got, c.want) + } + } +} + +// TestClaudeLaunchCommandLine_ReasoningEffort pins the flag contract: present +// as `--effort ` after the permission flag when configured, absent when +// unset (Claude Code keeps its own default), and the security deny flags are +// untouched either way. +func TestClaudeLaunchCommandLine_ReasoningEffort(t *testing.T) { + cmd := claudeLaunchCmd(t, "claude-fable-5", config.ClaudeEffortXHigh, false) + if !strings.HasPrefix(cmd, "claude --model claude-fable-5 --dangerously-skip-permissions --effort xhigh") { + t.Errorf("configured effort must ride after the permission flag as --effort; cmd: %q", cmd) + } + if strings.Count(cmd, "--effort") != 1 { + t.Errorf("--effort must appear exactly once; cmd: %q", cmd) + } + if !strings.Contains(cmd, claudeGitHubWriteDenyFlags) { + t.Errorf("effort must not displace the GitHub MCP write-deny flags; cmd: %q", cmd) + } + + if cmd := claudeLaunchCmd(t, "claude-fable-5", "", false); strings.Contains(cmd, "--effort") { + t.Errorf("no configured effort must mean no --effort (Claude Code's own default); cmd: %q", cmd) + } + if cmd := claudeLaunchCmd(t, "claude-fable-5", "minimal", false); strings.Contains(cmd, "--effort") { + t.Errorf("a value claude does not accept must be dropped, not passed; cmd: %q", cmd) + } +} + +// TestClaudeLaunchCommandLine_InferenceKeepsEffort pins the inference route +// (`--bare --settings`): the same claude binary, so the same --effort. +func TestClaudeLaunchCommandLine_InferenceKeepsEffort(t *testing.T) { + cmd := claudeLaunchCmd(t, "m", config.ClaudeEffortHigh, true) + if !strings.Contains(cmd, " --bare --settings "+claudeInferenceSettingsPath+" --effort high") { + t.Errorf("inference claude launch must carry --effort after --bare --settings; cmd: %q", cmd) + } + if cmd := claudeLaunchCmd(t, "m", "", true); strings.Contains(cmd, "--effort") { + t.Errorf("inference claude launch with no effort must not carry --effort; cmd: %q", cmd) + } +} + +// TestClaudeToolRulesLaunchCmd_CarriesEffort pins the ToolsConfig path to the +// same contract: --effort before the per-tool denies, and absent when unset. +func TestClaudeToolRulesLaunchCmd_CarriesEffort(t *testing.T) { + tools := &config.ToolsConfig{Rules: []config.ToolRule{{Pattern: "Bash(rm *)", Action: "deny"}}} + cmd := toolRulesToLaunchCmd("claude", "claude-fable-5", config.ClaudeBackend, tools, false, config.ClaudeEffortMax) + if !strings.HasPrefix(cmd, "claude --model claude-fable-5 --dangerously-skip-permissions --effort max") { + t.Errorf("tools path must carry --effort after the permission flag; cmd: %q", cmd) + } + if !strings.Contains(cmd, "--disallowed-tools 'Bash(rm *)'") { + t.Errorf("tools path must keep its deny rules; cmd: %q", cmd) + } + if cmd := toolRulesToLaunchCmd("claude", "claude-fable-5", config.ClaudeBackend, tools, false, ""); strings.Contains(cmd, "--effort") { + t.Errorf("tools path with no effort must not carry --effort; cmd: %q", cmd) + } +} + +// TestOtherBackendsIgnoreClaudeEffort pins that a configured effort reaches +// only the backends with an effort control: copilot, gemini, goose and bob +// get no --effort even when a claude-valid value is stored (a backend switch +// after an effort was set must not break the new backend's launch). +func TestOtherBackendsIgnoreClaudeEffort(t *testing.T) { + for _, backend := range []string{"copilot", "gemini", "goose", bobBackend, "unknown-cli"} { + cmd := backendLaunchCmd(backend, "m", backend, false, config.ClaudeEffortHigh) + if strings.Contains(cmd, "--effort") { + t.Errorf("%s must ignore a configured effort; cmd: %q", backend, cmd) + } + toolsCmd := toolRulesToLaunchCmd(backend, "m", backend, &config.ToolsConfig{}, false, config.ClaudeEffortHigh) + if strings.Contains(toolsCmd, "--effort") { + t.Errorf("%s (tools path) must ignore a configured effort; cmd: %q", backend, toolsCmd) + } + } +} + +// TestResolveReasoningEffort_Claude pins the attribution-trail rule: the +// configured value when claude accepts it (that is what --effort carried), +// "" when unset or when the value would have been dropped at launch. +func TestResolveReasoningEffort_Claude(t *testing.T) { + cases := []struct { + configured, want string + }{ + {"", ""}, + {config.ClaudeEffortMedium, "medium"}, + {config.ClaudeEffortMax, "max"}, + {"minimal", ""}, + } + for _, c := range cases { + if got := ResolveReasoningEffort(config.ClaudeBackend, "claude-fable-5", c.configured); got != c.want { + t.Errorf("ResolveReasoningEffort(claude, %q) = %q, want %q", c.configured, got, c.want) + } + // Unlike agy, claude's effort does not depend on a model being set. + if got := ResolveReasoningEffort(config.ClaudeBackend, "", c.configured); got != c.want { + t.Errorf("ResolveReasoningEffort(claude, no model, %q) = %q, want %q", c.configured, got, c.want) + } + } + if got := ResolveReasoningEffort("copilot", "m", config.ClaudeEffortHigh); got != "" { + t.Errorf("copilot must not report an effort it was never launched with; got %q", got) + } +} diff --git a/agent/claude_oauth_env_test.go b/agent/claude_oauth_env_test.go new file mode 100644 index 0000000..a964ee0 --- /dev/null +++ b/agent/claude_oauth_env_test.go @@ -0,0 +1,156 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/hivecommons/crux/config" +) + +// stageSharedClaudeCredential points the shared credential path at a temp file +// holding the given token set, and returns the path. +func stageSharedClaudeCredential(t *testing.T, oauth map[string]any) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".credentials.json") + if oauth != nil { + data, err := json.Marshal(map[string]any{"claudeAiOauth": oauth}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + orig := sharedClaudeCredentialPath + sharedClaudeCredentialPath = path + t.Cleanup(func() { sharedClaudeCredentialPath = orig }) + + // Redirect BOTH home roots agentClaudeCredentialPaths can resolve to, or + // the assertions read a live credential instead of the fixture: the shared + // /data/home on a developer box that happens to run a hive, and — for an + // agent with no per-agent UID — the running user's own $HOME. + origHome := sharedAgentHome + sharedAgentHome = filepath.Join(t.TempDir(), "home") + t.Cleanup(func() { sharedAgentHome = origHome }) + t.Setenv("HOME", filepath.Join(t.TempDir(), "user-home")) + + return path +} + +func claudeAgentFixture(t *testing.T, token string) (*Manager, *AgentProcess) { + t.Helper() + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "claude", Model: "claude-sonnet-5"}, + }, discardLogger(), ProjectContext{ACMMLevel: 5}) + m.mu.Lock() + m.claudeAuthToken = token + m.mu.Unlock() + m.mu.RLock() + agent := m.agents["scanner"] + m.mu.RUnlock() + if agent == nil { + t.Fatal("fixture agent not registered") + } + return m, agent +} + +func envPairsByKey(m *Manager, agent *AgentProcess) map[string]agentEnvPair { + out := map[string]agentEnvPair{} + for _, p := range m.agentEnvPairs(agent) { + out[p.Key] = p + } + return out +} + +// TestClaudeOAuthEnv_NotInjectedOverAReadableCredential is the defect of #5454. +// +// CLAUDE_CODE_OAUTH_TOKEN is a static bearer override: with it set, Claude Code +// uses the value verbatim and never opens the credentials file, so it can never +// refresh. The value hive injected was a snapshot of the SHORT-LIVED access +// token taken once at manager construction, which pinned every claude agent to +// the 8h life of whatever token was on disk at container start. Once the +// agent can read the credential itself (true since per-agent homes, #4619), +// injecting the override only takes away the CLI's ability to recover. +func TestClaudeOAuthEnv_NotInjectedOverAReadableCredential(t *testing.T) { + stageSharedClaudeCredential(t, map[string]any{ + "accessToken": "sk-ant-oat-live", + "expiresAt": time.Now().Add(6 * time.Hour).UnixMilli(), + }) + m, agent := claudeAgentFixture(t, "sk-ant-oat-snapshot-from-boot") + + if _, ok := envPairsByKey(m, agent)["CLAUDE_CODE_OAUTH_TOKEN"]; ok { + t.Fatal("injected the static token override over a credential file the CLI can read and refresh") + } +} + +// The same must hold for the state a fleet enters roughly once a day: the +// access token has aged out, the refresh grant has not, and the next CLI start +// mints a new token from the file. Re-injecting the override here would defeat +// the recovery at the exact moment it was about to happen. +func TestClaudeOAuthEnv_NotInjectedOverARefreshableCredential(t *testing.T) { + stageSharedClaudeCredential(t, map[string]any{ + "accessToken": "sk-ant-oat-aged-out", + "expiresAt": time.Now().Add(-2 * time.Hour).UnixMilli(), + "refreshToken": "sk-ant-ort-live", + "refreshTokenExpiresAt": time.Now().Add(28 * 24 * time.Hour).UnixMilli(), + }) + m, agent := claudeAgentFixture(t, "sk-ant-oat-snapshot-from-boot") + + if _, ok := envPairsByKey(m, agent)["CLAUDE_CODE_OAUTH_TOKEN"]; ok { + t.Fatal("injected the static token override over a credential the CLI was about to refresh") + } +} + +// The job the variable was added for (c5648bc9) must survive: an agent with no +// credential file it can read still gets the dashboard-obtained token, as a +// secret pair so the value never reaches a command line. +func TestClaudeOAuthEnv_StillInjectedWithNoReadableCredential(t *testing.T) { + stageSharedClaudeCredential(t, nil) // path exists in name only; no file written + m, agent := claudeAgentFixture(t, "sk-ant-oat-from-dashboard-login") + + pair, ok := envPairsByKey(m, agent)["CLAUDE_CODE_OAUTH_TOKEN"] + if !ok { + t.Fatal("an agent with no readable credential must still receive the injected token") + } + if pair.Value != "sk-ant-oat-from-dashboard-login" { + t.Fatalf("value = %q", pair.Value) + } + if !pair.Secret { + t.Fatal("the token must stay a secret pair — it must never reach a command line or pane scrollback") + } +} + +// A spent credential (expired, no refresh grant) is not something the CLI can +// recover from, so the fallback still applies. +func TestClaudeOAuthEnv_InjectedOverASpentCredential(t *testing.T) { + stageSharedClaudeCredential(t, map[string]any{ + "accessToken": "sk-ant-oat-spent", + "expiresAt": time.Now().Add(-time.Hour).UnixMilli(), + }) + m, agent := claudeAgentFixture(t, "sk-ant-oat-from-dashboard-login") + + if _, ok := envPairsByKey(m, agent)["CLAUDE_CODE_OAUTH_TOKEN"]; !ok { + t.Fatal("a spent credential leaves nothing for the CLI to refresh; the fallback must still inject") + } +} + +// Non-claude backends never carried this variable and still must not. +func TestClaudeOAuthEnv_NotInjectedForOtherBackends(t *testing.T) { + stageSharedClaudeCredential(t, nil) + m := NewManager(map[string]config.AgentConfig{ + "scanner": {Backend: "copilot"}, + }, discardLogger(), ProjectContext{ACMMLevel: 5}) + m.mu.Lock() + m.claudeAuthToken = "sk-ant-oat-from-dashboard-login" + m.mu.Unlock() + m.mu.RLock() + agent := m.agents["scanner"] + m.mu.RUnlock() + + if _, ok := envPairsByKey(m, agent)["CLAUDE_CODE_OAUTH_TOKEN"]; ok { + t.Fatal("a copilot agent must not receive a Claude token") + } +} diff --git a/agent/claude_remote_control.go b/agent/claude_remote_control.go new file mode 100644 index 0000000..1c5536f --- /dev/null +++ b/agent/claude_remote_control.go @@ -0,0 +1,113 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// remoteControlSettingKey is the Claude Code settings key that controls +// whether the Remote Control bridge starts with the session. Honored from +// user settings; an absent key delegates the decision to a server-side +// rollout, which is exactly what this pin exists to prevent. +const remoteControlSettingKey = "remoteControlAtStartup" + +// remoteControlUsedKey is the ~/.claude.json marker the CLI persists once the +// bridge has actually run in a session (observed as hasUsedRemoteControl: true +// on the #5607 fleet). Read-only here: it is the launch-time evidence that the +// bridge came up despite (or before) the pin. +const remoteControlUsedKey = "hasUsedRemoteControl" + +// claudeRemoteControlSeed returns the key hive pins into the shared Claude +// user settings for interactive claude-backend agents. Kept minimal on +// purpose: the shared /data/home/.claude/settings.json also carries +// fleet-critical keys hive does not own (skipDangerousModePermissionPrompt, +// enabledPlugins, ...), and the seed must never be a reason to touch them. +func claudeRemoteControlSeed() map[string]any { + return map[string]any{remoteControlSettingKey: false} +} + +// sharedClaudeSettingsFile is the userSettings file every interactive +// claude-backend agent reads: the per-agent homes symlink ~/.claude to +// sharedAgentHome/.claude (interactiveHomeBridgeDirs), so one file governs +// the whole fleet. +func sharedClaudeSettingsFile() string { + return filepath.Join(sharedAgentHome, ".claude", "settings.json") +} + +// ensureClaudeRemoteControlDefault re-asserts, before a claude-backend launch, +// that Remote Control does not auto-start for the fleet. Called on EVERY +// launch so a relaunch can never fall back to the server-side rollout default, +// and so an out-of-band deletion of the key self-heals on the next launch — +// the same posture as ensureBobAuthSettings for bob's shared settings. +// +// Semantics: +// - key absent -> written as false (the pin; this is the whole fix) +// - key present -> left untouched, true OR false (operator intent wins); +// an explicit true is logged as a receipt so an enabled bridge is always +// explainable from the hive log +// - unparseable file -> rewritten from the seed alone (seedJSONFile +// semantics; the CLI could not read the old keys either) +// +// Errors are logged and swallowed: a settings file hive cannot write is not a +// reason to refuse a launch that may still succeed. +func (m *Manager) ensureClaudeRemoteControlDefault(agent *AgentProcess) { + path := sharedClaudeSettingsFile() + // The entrypoint pre-creates /data/home/.claude (it holds the shared OAuth + // credential) before the hive starts. If it is missing there is no login + // and therefore no bridge entitlement either, so skip rather than invent a + // directory whose modes the entrypoint owns. + if _, err := os.Lstat(filepath.Dir(path)); err != nil { + m.logger.Debug("shared claude settings dir absent; skipping remote-control pin", + "agent", agent.Name, "dir", filepath.Dir(path), "error", err) + return + } + m.seedJSONFile(agent.Name, path, claudeRemoteControlSeed()) + if enabled, explicit := readRemoteControlSetting(path); explicit && enabled { + // Receipt, not an error: the operator opted in and hive honored it. + m.logger.Info("claude remote control left ON by explicit operator setting", + "agent", agent.Name, "path", path, "key", remoteControlSettingKey) + } + home := AgentHome(agent.Name, agent.UID, "claude") + if m.remoteControlBridgeUsed(home) { + m.logger.Warn("claude remote control bridge has run for this agent despite the startup pin — history predating the pin, an explicit operator opt-in, or the pin not being honored", + "agent", agent.Name, "session_file", claudeSessionFile(home), + "marker", remoteControlUsedKey) + } +} + +// readRemoteControlSetting reports the value of remoteControlAtStartup in the +// settings file at path and whether it is explicitly present as a bool. +func readRemoteControlSetting(path string) (value, explicit bool) { + data, err := readInferenceConfigFile(path) + if err != nil { + return false, false + } + settings := map[string]any{} + if json.Unmarshal(data, &settings) != nil { + return false, false + } + v, ok := settings[remoteControlSettingKey].(bool) + return v, ok +} + +// remoteControlBridgeUsed reports whether the agent's Claude session file +// records the Remote Control bridge having actually run. Best-effort and +// read-only: on privileged deployments the per-agent 0600 session file is +// readable by the hive; where it is not, the check silently reports false +// rather than blocking anything. +func (m *Manager) remoteControlBridgeUsed(home string) bool { + if home == "" { + return false + } + data, err := readInferenceConfigFile(claudeSessionFile(home)) + if err != nil { + return false + } + session := map[string]any{} + if json.Unmarshal(data, &session) != nil { + return false + } + used, _ := session[remoteControlUsedKey].(bool) + return used +} diff --git a/agent/claude_remote_control_test.go b/agent/claude_remote_control_test.go new file mode 100644 index 0000000..67d7a43 --- /dev/null +++ b/agent/claude_remote_control_test.go @@ -0,0 +1,168 @@ +package agent + +// Remote Control startup pin (#5607) — property tests. +// +// The invariant under test: every Claude-CLI launch surface hive provisions +// carries an EXPLICIT "remoteControlAtStartup": false, because an absent key +// delegates the decision to a server-side rollout that flipped to auto-ON. +// Presence of the key is the security property; merge-only semantics (an +// operator's explicit true survives) is the escape hatch. + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/hivecommons/crux/config" +) + +func readSettingsMap(t *testing.T, path string) map[string]any { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + parsed := map[string]any{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("invalid JSON in %s: %v", path, err) + } + return parsed +} + +func assertRemoteControlOff(t *testing.T, path string) { + t.Helper() + parsed := readSettingsMap(t, path) + v, ok := parsed[remoteControlSettingKey] + if !ok { + t.Fatalf("%s: %s key ABSENT — an absent key delegates to the server-side rollout default, which is the #5607 exposure", path, remoteControlSettingKey) + } + if v != false { + t.Fatalf("%s: %s = %v, want false", path, remoteControlSettingKey, v) + } +} + +// --- inference seed ---------------------------------------------------------- + +func TestInferenceSettingsSeed_PinsRemoteControlOff(t *testing.T) { + seed := inferenceSettingsSeed() + v, ok := seed[remoteControlSettingKey] + if !ok { + t.Fatalf("inferenceSettingsSeed missing %s — inference launches would fall back to the server-side rollout default", remoteControlSettingKey) + } + if v != false { + t.Fatalf("inferenceSettingsSeed[%s] = %v, want false", remoteControlSettingKey, v) + } +} + +func TestEnsureClaudeSettings_PinsRemoteControlOff(t *testing.T) { + orig := inferenceHomePrefixOverride + inferenceHomePrefixOverride = filepath.Join(t.TempDir(), "inference-home") + "-" + t.Cleanup(func() { inferenceHomePrefixOverride = orig }) + os.Remove(claudeInferenceSettingsPath) + + m := NewManager(map[string]config.AgentConfig{}, discardLogger(), ProjectContext{}) + m.ensureClaudeSettings("rc-pin", 0) + + // Both settings surfaces the CLI reads for an inference agent: + // userSettings in the per-agent HOME and the --settings flagSettings file. + assertRemoteControlOff(t, filepath.Join(inferenceHomePath("rc-pin"), ".claude", "settings.json")) + assertRemoteControlOff(t, claudeInferenceSettingsPath) +} + +// --- shared interactive settings -------------------------------------------- + +// withSharedClaudeDir provisions the shared home and its .claude dir (the +// entrypoint's job in production) and returns the settings path. +func withSharedClaudeDir(t *testing.T) string { + t.Helper() + shared := withSharedAgentHome(t) + if err := os.MkdirAll(filepath.Join(shared, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + return sharedClaudeSettingsFile() +} + +func rcTestAgent() *AgentProcess { + return &AgentProcess{Name: "scanner", UID: 1001} +} + +func TestEnsureClaudeRemoteControlDefault_SeedsFalse(t *testing.T) { + path := withSharedClaudeDir(t) + m := quietManager() + + m.ensureClaudeRemoteControlDefault(rcTestAgent()) + assertRemoteControlOff(t, path) + + // Idempotent: a second launch leaves the pin in place unchanged. + m.ensureClaudeRemoteControlDefault(rcTestAgent()) + assertRemoteControlOff(t, path) +} + +func TestEnsureClaudeRemoteControlDefault_PreservesOperatorTrue(t *testing.T) { + path := withSharedClaudeDir(t) + if err := os.WriteFile(path, []byte(`{"remoteControlAtStartup":true,"theme":"dark"}`), 0o666); err != nil { + t.Fatal(err) + } + + quietManager().ensureClaudeRemoteControlDefault(rcTestAgent()) + + parsed := readSettingsMap(t, path) + if parsed[remoteControlSettingKey] != true { + t.Fatalf("explicit operator opt-in was clobbered: %s = %v, want true", remoteControlSettingKey, parsed[remoteControlSettingKey]) + } + if parsed["theme"] != "dark" { + t.Fatalf("sibling key clobbered: theme = %v", parsed["theme"]) + } +} + +func TestEnsureClaudeRemoteControlDefault_PreservesFleetKeys(t *testing.T) { + // The shared file carries keys hive does not own and the fleet cannot run + // without (the bypass-permissions consent suppressor above all). The pin + // must be add-only. + path := withSharedClaudeDir(t) + existing := `{"skipDangerousModePermissionPrompt":true,"bypassPermissions":true,"hasCompletedOnboarding":true}` + if err := os.WriteFile(path, []byte(existing), 0o666); err != nil { + t.Fatal(err) + } + + quietManager().ensureClaudeRemoteControlDefault(rcTestAgent()) + + parsed := readSettingsMap(t, path) + assertRemoteControlOff(t, path) + for _, key := range []string{"skipDangerousModePermissionPrompt", "bypassPermissions", "hasCompletedOnboarding"} { + if parsed[key] != true { + t.Fatalf("fleet key %s = %v after pin, want true", key, parsed[key]) + } + } +} + +func TestEnsureClaudeRemoteControlDefault_SkipsWhenClaudeDirAbsent(t *testing.T) { + shared := withSharedAgentHome(t) // no .claude dir: entrypoint owns creating it + quietManager().ensureClaudeRemoteControlDefault(rcTestAgent()) + if _, err := os.Lstat(filepath.Join(shared, ".claude")); !os.IsNotExist(err) { + t.Fatalf(".claude dir should not be invented by the pin (err=%v)", err) + } +} + +// --- runtime evidence check -------------------------------------------------- + +func TestRemoteControlBridgeUsed(t *testing.T) { + withSharedAgentHome(t) + m := quietManager() + + home := interactiveHomePath("scanner") + if m.remoteControlBridgeUsed(home) { + t.Fatal("missing session file should report bridge unused") + } + + writeSession(t, home, `{"hasCompletedOnboarding":true}`) + if m.remoteControlBridgeUsed(home) { + t.Fatal("session without the marker should report bridge unused") + } + + writeSession(t, home, `{"hasCompletedOnboarding":true,"hasUsedRemoteControl":true}`) + if !m.remoteControlBridgeUsed(home) { + t.Fatal("session with hasUsedRemoteControl:true should report bridge used — this is the launch-time evidence log for #5607") + } +} diff --git a/agent/claude_session_adopt.go b/agent/claude_session_adopt.go new file mode 100644 index 0000000..e282345 --- /dev/null +++ b/agent/claude_session_adopt.go @@ -0,0 +1,175 @@ +package agent + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "syscall" + "time" +) + +// claudeSessionAdoptMaxBytes caps how much of a .claude.json this will carry +// across UIDs. Claude Code accumulates per-project history in the file, so it +// grows without a natural bound; a few MiB is far above any observed session +// file while keeping a rogue or corrupted path from being slurped into memory. +const claudeSessionAdoptMaxBytes = 8 << 20 + +// claudeSessionAdoptTimeout bounds one su-exec helper invocation. The helpers +// are cat/sh against a local file: anything slower than this is a wedged +// process, not slow I/O, and a launch must not block on it. +const claudeSessionAdoptTimeout = 10 * time.Second + +// runAsAgentUser runs argv as the su-exec user spec, feeds it stdin, and +// returns its stdout. +// +// Var (not func) as a TEST SEAM, matching the sharedAgentHome convention: unit +// tests cannot create files owned by another UID, so they substitute a runner +// that stands in for the privileged read. The production body is deliberately +// the same su-exec invocation setupCodexHome uses. +var runAsAgentUser = func(spec string, stdin []byte, argv ...string) ([]byte, error) { + if spec == "" || len(argv) == 0 { + return nil, errors.New("runAsAgentUser: empty user spec or argv") + } + if _, err := exec.LookPath("su-exec"); err != nil { + return nil, fmt.Errorf("su-exec unavailable: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), claudeSessionAdoptTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "su-exec", append([]string{spec}, argv...)...) + var stdout, stderr bytes.Buffer + cmd.Stdin = bytes.NewReader(stdin) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, outputErr("su-exec "+spec, err, stderr.Bytes()) + } + return stdout.Bytes(), nil +} + +// claudeSessionOwnerSpec returns the su-exec user spec for the owner of the +// session file at path, or an error explaining why no helper may run for it. +// +// Fails CLOSED, unlike inferenceHomeIsOwnedBy: the result decides whose +// identity a subprocess runs under, so every doubt (not a regular file, a +// symlink, oversized, no unix ownership, owned by root) is a refusal rather +// than a guess. Lstat, never Stat — a symlink planted at a sibling's +// .claude.json must not resolve to the ownership of whatever it points at. +func claudeSessionOwnerSpec(path string) (string, error) { + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("%s is not a regular file (mode %s)", path, info.Mode()) + } + if info.Size() > claudeSessionAdoptMaxBytes { + return "", fmt.Errorf("%s is %d bytes, over the %d-byte adoption cap", path, info.Size(), claudeSessionAdoptMaxBytes) + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "", fmt.Errorf("%s: no unix ownership available", path) + } + if st.Uid == 0 { + return "", fmt.Errorf("%s is owned by uid 0; refusing to run a helper as root", path) + } + return fmt.Sprintf("%d:%d", st.Uid, st.Gid), nil +} + +// inspectClaudeSessionForAdoption classifies path exactly as +// inspectClaudeSession does, except that claudeSessionUnreadable is retried as +// the file's owner instead of being reported as a dead end. +// +// Both callers in the seeding path need this and for opposite reasons: a +// SOURCE that is unreadable is a login this fleet is failing to propagate, and +// a TARGET that is unreadable may be the agent's OWN signed-in session — which +// adopt-only must never overwrite. Before this, an unreadable target fell +// through to "not signed in" and was clobbered by a sibling's identity. +func (m *Manager) inspectClaudeSessionForAdoption(path string) claudeSessionInfo { + info := inspectClaudeSession(path) + if info.State != claudeSessionUnreadable { + return info + } + data, err := m.readClaudeSessionAsOwner(path) + if err != nil { + // Debug, not Warn: on any deployment without su-exec this is the + // ordinary outcome, and diagnoseStuckLogin already reports the + // unreadable state where an operator will see it. + m.logger.Debug("could not read agent-owned claude session", + "path", path, "error", err) + return info + } + return classifyClaudeSession(path, data) +} + +// readClaudeSessionAsOwner reads path through su-exec as the file's owner. +func (m *Manager) readClaudeSessionAsOwner(path string) ([]byte, error) { + spec, err := claudeSessionOwnerSpec(path) + if err != nil { + return nil, err + } + data, err := runAsAgentUser(spec, nil, "cat", path) + if err != nil { + return nil, err + } + if len(data) > claudeSessionAdoptMaxBytes { + // The Lstat cap above is advisory — the file can grow between the stat + // and the read — so re-check what actually came back. + return nil, fmt.Errorf("%s returned %d bytes, over the %d-byte adoption cap", + path, len(data), claudeSessionAdoptMaxBytes) + } + return data, nil +} + +// readClaudeSessionForAdoption reads a seed source, falling back to a read as +// the file's owner when this process is not permitted to open it. +func (m *Manager) readClaudeSessionForAdoption(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err == nil || !errors.Is(err, fs.ErrPermission) { + return data, err + } + owned, oerr := m.readClaudeSessionAsOwner(path) + if oerr != nil { + return nil, errors.Join(err, oerr) + } + return owned, nil +} + +// writeClaudeSessionForAgent writes the adopted session to target, falling +// back to a write performed BY the owning agent when this process cannot write +// it directly — which is the fresh-install case, where target is the agent's +// own 0600 file. +// +// The fallback stages through a temp file in the target's own directory and +// renames, so a helper that dies mid-write cannot leave the agent holding a +// truncated .claude.json. target is passed as $0 rather than interpolated into +// the script, so a path is data to the shell and never code. +// +// The staged file is chmod'ed to claudeSessionSeedFileMode — the SAME mode the +// direct write above uses, and deliberately not the 0600 the agent's own CLI +// would produce. A seeded session has to stay readable by the hive uid: +// #4636's live investigation found that a CLI spawned by the manager against a +// session file the hive process cannot read treats the home as a fresh install +// and rewrites the signed-in identity away. Agents whose files had been seeded +// at this mode survived that; agents holding an agent-written 0600 file did +// not. Both write paths must therefore land on the same mode or adoption would +// reintroduce the clobber it exists to prevent. The containment is the home +// itself (0700, agent-owned — see tightenInteractiveHome), exactly the +// inferenceConfigFileMode trade-off. +func (m *Manager) writeClaudeSessionForAgent(target string, data []byte, spec string) error { + err := os.WriteFile(target, data, claudeSessionSeedFileMode) + if err == nil || spec == "" || !errors.Is(err, fs.ErrPermission) { + return err + } + stageAndRename := fmt.Sprintf( + `tmp="$0.hive-seed.$$"; cat >"$tmp" && chmod %#o "$tmp" && mv -f "$tmp" "$0" `+ + `|| { rm -f "$tmp"; exit 1; }`, claudeSessionSeedFileMode) + if _, werr := runAsAgentUser(spec, data, "sh", "-c", stageAndRename, target); werr != nil { + return errors.Join(err, werr) + } + return nil +} diff --git a/agent/claude_session_adopt_fallback_test.go b/agent/claude_session_adopt_fallback_test.go new file mode 100644 index 0000000..78b7f5c --- /dev/null +++ b/agent/claude_session_adopt_fallback_test.go @@ -0,0 +1,264 @@ +package agent + +// Covers the fallback machinery of claude_session_adopt.go (#4637) that the +// seeding-path tests never reach: the PRODUCTION runAsAgentUser body (exercised +// against a fake su-exec on PATH), the stage-and-rename write fallback in +// writeClaudeSessionForAgent, the post-read size-cap re-check in +// readClaudeSessionAsOwner, and the errors.Join paths that must preserve BOTH +// the direct-I/O error and the helper error for diagnoseStuckLogin. + +import ( + "bytes" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +// withFakeSuExec prepends a directory holding a fake su-exec to PATH. The fake +// drops the "uid:gid" spec argument and execs the rest of argv directly, which +// is exactly the observable contract production relies on: spec first, command +// after, stdin/stdout/stderr passed through. +func withFakeSuExec(t *testing.T) { + t.Helper() + dir := t.TempDir() + script := "#!/bin/sh\nshift\nexec \"$@\"\n" + if err := os.WriteFile(filepath.Join(dir, "su-exec"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// swapAgentUserRunner replaces the runAsAgentUser seam for one test. +func swapAgentUserRunner(t *testing.T, fn func(spec string, stdin []byte, argv ...string) ([]byte, error)) { + t.Helper() + old := runAsAgentUser + runAsAgentUser = fn + t.Cleanup(func() { runAsAgentUser = old }) +} + +// --- the production su-exec runner --------------------------------------------- + +func TestRunAsAgentUser_PassesStdinAndReturnsStdout(t *testing.T) { + withFakeSuExec(t) + out, err := runAsAgentUser("1001:1001", []byte("session-bytes"), "cat") + if err != nil { + t.Fatalf("runAsAgentUser = %v, want success via fake su-exec", err) + } + if got := string(out); got != "session-bytes" { + t.Errorf("stdout = %q, want stdin echoed back", got) + } +} + +func TestRunAsAgentUser_FailureCarriesStderr(t *testing.T) { + withFakeSuExec(t) + _, err := runAsAgentUser("1001:1001", nil, "sh", "-c", "echo boom >&2; exit 3") + if err == nil { + t.Fatal("a failing helper must surface an error") + } + if !strings.Contains(err.Error(), "boom") { + t.Errorf("error %q should carry the helper's stderr", err) + } + if !strings.Contains(err.Error(), "1001:1001") { + t.Errorf("error %q should name the user spec it ran as", err) + } +} + +func TestRunAsAgentUser_SuExecAbsentFailsClosed(t *testing.T) { + t.Setenv("PATH", t.TempDir()) // nothing on PATH at all + _, err := runAsAgentUser("1001:1001", nil, "cat", "/dev/null") + if err == nil || !strings.Contains(err.Error(), "su-exec unavailable") { + t.Errorf("err = %v, want the su-exec-unavailable refusal", err) + } +} + +// --- owner spec refusals --------------------------------------------------------- + +func TestClaudeSessionOwnerSpec_RefusesRootOwnedFile(t *testing.T) { + const path = "/etc/hostname" + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + t.Skipf("no root-owned regular file to probe at %s", path) + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || st.Uid != 0 { + t.Skipf("%s is not uid-0 owned here", path) + } + if _, err := claudeSessionOwnerSpec(path); err == nil || + !strings.Contains(err.Error(), "uid 0") { + t.Errorf("err = %v, want the refusal to run a helper as root", err) + } +} + +// --- owner reads ----------------------------------------------------------------- + +func TestReadClaudeSessionAsOwner_SpecFailureShortCircuits(t *testing.T) { + m := interactiveHomeTestManager(t) + called := false + swapAgentUserRunner(t, func(string, []byte, ...string) ([]byte, error) { + called = true + return nil, nil + }) + if _, err := m.readClaudeSessionAsOwner(filepath.Join(t.TempDir(), "missing.json")); err == nil { + t.Error("a path with no owner spec must not be readable") + } + if called { + t.Error("no helper may run for a path whose owner spec was refused") + } +} + +func TestReadClaudeSessionAsOwner_RechecksSizeCapAfterRead(t *testing.T) { + withSharedAgentHome(t) + m := interactiveHomeTestManager(t) + path := writeUnreadableSession(t, interactiveHomePath("guide"), signedInClaudeSession) + + // The Lstat cap passes (file is tiny) but the helper returns a payload + // that grew past the cap between stat and read. + swapAgentUserRunner(t, func(string, []byte, ...string) ([]byte, error) { + return bytes.Repeat([]byte("x"), claudeSessionAdoptMaxBytes+1), nil + }) + _, err := m.readClaudeSessionAsOwner(path) + if err == nil || !strings.Contains(err.Error(), "cap") { + t.Errorf("err = %v, want the post-read adoption-cap refusal", err) + } +} + +func TestReadClaudeSessionForAdoption_JoinsDirectAndHelperErrors(t *testing.T) { + withSharedAgentHome(t) + m := interactiveHomeTestManager(t) + path := writeUnreadableSession(t, interactiveHomePath("guide"), signedInClaudeSession) + + helperErr := errors.New("helper wedged") + swapAgentUserRunner(t, func(string, []byte, ...string) ([]byte, error) { + return nil, helperErr + }) + _, err := m.readClaudeSessionForAdoption(path) + if err == nil { + t.Fatal("both reads failed; an error is required") + } + if !errors.Is(err, fs.ErrPermission) { + t.Errorf("joined error %v must preserve the direct EACCES", err) + } + if !errors.Is(err, helperErr) { + t.Errorf("joined error %v must preserve the helper failure", err) + } +} + +// --- the write fallback ---------------------------------------------------------- + +func TestWriteClaudeSessionForAgent_DirectWriteNeedsNoHelper(t *testing.T) { + m := interactiveHomeTestManager(t) + target := filepath.Join(t.TempDir(), ".claude.json") + called := false + swapAgentUserRunner(t, func(string, []byte, ...string) ([]byte, error) { + called = true + return nil, nil + }) + if err := m.writeClaudeSessionForAgent(target, []byte(signedInClaudeSession), "1001:1001"); err != nil { + t.Fatalf("direct write = %v, want success", err) + } + if called { + t.Error("a writable target must never invoke the su-exec helper") + } + data, err := os.ReadFile(target) + if err != nil || string(data) != signedInClaudeSession { + t.Errorf("target = %q, %v; want the seeded session", data, err) + } +} + +func TestWriteClaudeSessionForAgent_NonPermissionErrorIsNotRetried(t *testing.T) { + m := interactiveHomeTestManager(t) + called := false + swapAgentUserRunner(t, func(string, []byte, ...string) ([]byte, error) { + called = true + return nil, nil + }) + target := filepath.Join(t.TempDir(), "no-such-dir", ".claude.json") + err := m.writeClaudeSessionForAgent(target, []byte("{}"), "1001:1001") + if err == nil || errors.Is(err, fs.ErrPermission) { + t.Fatalf("err = %v, want a non-permission direct failure", err) + } + if called { + t.Error("only EACCES may fall back to the agent-identity write") + } +} + +func TestWriteClaudeSessionForAgent_EmptySpecCannotFallBack(t *testing.T) { + withSharedAgentHome(t) + m := interactiveHomeTestManager(t) + target := writeUnreadableSession(t, interactiveHomePath("scanner"), skeletonClaudeSession) + called := false + swapAgentUserRunner(t, func(string, []byte, ...string) ([]byte, error) { + called = true + return nil, nil + }) + err := m.writeClaudeSessionForAgent(target, []byte(signedInClaudeSession), "") + if !errors.Is(err, fs.ErrPermission) { + t.Errorf("err = %v, want the direct EACCES back unchanged", err) + } + if called { + t.Error("an empty user spec must never reach the helper") + } +} + +func TestWriteClaudeSessionForAgent_FallbackStagesThroughOwnedTemp(t *testing.T) { + withSharedAgentHome(t) + m := interactiveHomeTestManager(t) + target := writeUnreadableSession(t, interactiveHomePath("scanner"), skeletonClaudeSession) + + var gotSpec string + var gotArgv []string + var gotStdin []byte + swapAgentUserRunner(t, func(spec string, stdin []byte, argv ...string) ([]byte, error) { + gotSpec, gotStdin = spec, append([]byte(nil), stdin...) + gotArgv = append([]string(nil), argv...) + return nil, nil + }) + if err := m.writeClaudeSessionForAgent(target, []byte(signedInClaudeSession), "1001:1001"); err != nil { + t.Fatalf("fallback write = %v, want success", err) + } + if gotSpec != "1001:1001" { + t.Errorf("helper ran as %q, want the target agent's spec", gotSpec) + } + if string(gotStdin) != signedInClaudeSession { + t.Errorf("helper stdin = %q, want the session bytes", gotStdin) + } + // sh -c